Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d8a95095a | |||
| 7e21cd06b3 | |||
| 09c648b934 | |||
| 4f516657da | |||
| 990a4a99e9 | |||
| 5b622389c5 | |||
| a820a95ebb |
@@ -530,3 +530,23 @@ func TestClarifyIsPerConversation(t *testing.T) {
|
|||||||
func voiceCtx() context.Context {
|
func voiceCtx() context.Context {
|
||||||
return withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
return withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestARestartExpiresTheParkedQuestion pins the Vikunja #385 decision: the
|
||||||
|
// question dies with the process, and she does not claim to have let it go —
|
||||||
|
// the words that follow are routed as a fresh request. Restarting is modelled
|
||||||
|
// the way the daemon does it, by building a second handler over the same store.
|
||||||
|
func TestARestartExpiresTheParkedQuestion(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
ctx := voiceCtx()
|
||||||
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
|
t.Fatal("expected a question before the restart")
|
||||||
|
}
|
||||||
|
|
||||||
|
restarted, _, _ := newClarifyHandler(t)
|
||||||
|
if _, handled := restarted.resolveClarifyAnswer(ctx, "в 11:00"); handled {
|
||||||
|
t.Fatal("a question parked before the restart must not eat the next utterance")
|
||||||
|
}
|
||||||
|
if notice := restarted.clarifyExpiredNotice(ctx); notice != "" {
|
||||||
|
t.Fatalf("notice = %q, want silence: nothing survived to expire", notice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -514,11 +514,14 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
|
|
||||||
// Resolve the utterance text as an entity reference through Nexus. An
|
// Resolve the utterance text as an entity reference through Nexus. An
|
||||||
// ambiguous match must stop and clarify — never guess a mutation target.
|
// ambiguous match must stop and clarify — never guess a mutation target.
|
||||||
|
// The name comes from entityReferenceText, not straight from the Text slot:
|
||||||
|
// the model transliterates Latin names as it routes (Vikunja #476).
|
||||||
|
subject := entityReferenceText(dec)
|
||||||
started := h.now()
|
started := h.now()
|
||||||
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
|
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, subject, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
|
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||||
if unauthorizedEcosystemError(err) {
|
if unauthorizedEcosystemError(err) {
|
||||||
return "экосистема отклоняет доступ, проверь токен."
|
return "экосистема отклоняет доступ, проверь токен."
|
||||||
}
|
}
|
||||||
@@ -535,7 +538,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
}
|
}
|
||||||
if entityID == "" {
|
if entityID == "" {
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
||||||
map[string]any{"subject": redactSubject(dec.Slots.Text)})
|
map[string]any{"subject": redactSubject(subject)})
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
|
||||||
@@ -569,10 +572,21 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
}
|
}
|
||||||
verbLower := strings.ToLower(verb)
|
verbLower := strings.ToLower(verb)
|
||||||
|
|
||||||
|
// With no allowlisted fn the verb is a whole phrase ("restart status muzick
|
||||||
|
// indexer"), which no capability name ever contains. Read it the other way
|
||||||
|
// round then: the phrase is the haystack and the capability name is what we
|
||||||
|
// look for in it (Vikunja #476). Only when the fn slot is empty — a matched
|
||||||
|
// fn is a single verb and containment already means what it says.
|
||||||
|
loose := !dec.Slots.HasFn
|
||||||
var matches []*hexisclient.Capability
|
var matches []*hexisclient.Capability
|
||||||
for i, c := range caps {
|
for i, c := range caps {
|
||||||
if strings.Contains(strings.ToLower(c.Name), verbLower) ||
|
name := strings.ToLower(c.Name)
|
||||||
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower)) {
|
hit := strings.Contains(name, verbLower) ||
|
||||||
|
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower))
|
||||||
|
if loose && name != "" && strings.Contains(verbLower, name) {
|
||||||
|
hit = true
|
||||||
|
}
|
||||||
|
if hit {
|
||||||
matches = append(matches, &caps[i])
|
matches = append(matches, &caps[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -632,3 +646,29 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
|||||||
})
|
})
|
||||||
return "команда выполнена для " + displayName + "."
|
return "команда выполнена для " + displayName + "."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hexisBeforeClarify gives an entity-shaped act one chance at Hexis before she
|
||||||
|
// asks what to do.
|
||||||
|
//
|
||||||
|
// The stage-3 gate thins an act that never matched an allowlisted fn, so
|
||||||
|
// "перезапусти muzick indexer" was answered with "Что сделать?" and the Hexis
|
||||||
|
// path was never entered — the capability existed and no utterance could reach
|
||||||
|
// it (Vikunja #476). Hexis is exactly where an act with no local fn belongs:
|
||||||
|
// the verb is matched against the capabilities Hexis registers for the entity,
|
||||||
|
// not against the allowlist.
|
||||||
|
//
|
||||||
|
// Narrow on purpose. Only an act, only when the fn slot is still empty, and
|
||||||
|
// only when Hexis is wired — a box with no ecosystem asks the question it
|
||||||
|
// always asked. A "" back means Nexus knew no such entity or Hexis had no
|
||||||
|
// matching capability, and then she asks after all. Authority is unchanged:
|
||||||
|
// resolution stops on ambiguity and a mutating capability still goes through
|
||||||
|
// the spoken confirm in handleHexisAct.
|
||||||
|
func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Decision) string {
|
||||||
|
if h.ecosystem == nil || h.ecosystem.hexis == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if dec.Intent != router.IntentAct || dec.Slots.HasFn || dec.Slots.Text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return h.handleHexisAct(ctx, dec)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// latinRun matches a run of Latin-script words — the shape a service, host or
|
||||||
|
// project name takes in a Russian sentence. Digits, dot, dash and underscore
|
||||||
|
// ride along because "muzick-indexer" and "nginx.conf" are one name, not two.
|
||||||
|
var latinRun = regexp.MustCompile(`[A-Za-z][A-Za-z0-9._-]*(?:\s+[A-Za-z][A-Za-z0-9._-]*)*`)
|
||||||
|
|
||||||
|
// hasLatin reports whether s carries a Latin letter.
|
||||||
|
func hasLatin(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.In(r, unicode.Latin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// entityReferenceText is the name Nexus is asked to resolve.
|
||||||
|
//
|
||||||
|
// Normally that is the router's Text slot, which is the verb phrase the model
|
||||||
|
// wrote. But the resident model rewrites a Russian utterance as it routes, and
|
||||||
|
// on the way it transliterates: "перезапусти muzick indexer" came back as
|
||||||
|
// "перезагрузить музик индексер" (Vikunja #476). Nexus is then asked for a
|
||||||
|
// service nobody has ever named, so the act cannot resolve its target even
|
||||||
|
// with every gate open.
|
||||||
|
//
|
||||||
|
// The recovery is deliberately narrow. Only when the utterance holds a Latin
|
||||||
|
// run and the model's Text holds none has a name certainly been rewritten —
|
||||||
|
// then the longest Latin run in his own words is the reference. Anything else
|
||||||
|
// keeps the Text slot, so an English utterance and a Russian entity name are
|
||||||
|
// both untouched. Un-transliterating the Cyrillic back is not attempted: the
|
||||||
|
// surface form he said is right there, and guessing at a reverse mapping would
|
||||||
|
// invent a second name to be wrong about.
|
||||||
|
func entityReferenceText(dec router.Decision) string {
|
||||||
|
text := dec.Slots.Text
|
||||||
|
if hasLatin(text) || !hasLatin(dec.Utterance) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
longest := ""
|
||||||
|
for _, m := range latinRun.FindAllString(dec.Utterance, -1) {
|
||||||
|
if len(m) > len(longest) {
|
||||||
|
longest = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
longest = strings.TrimSpace(longest)
|
||||||
|
// A single stray letter is not a name.
|
||||||
|
if len(longest) < 2 {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return longest
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntityReferenceText pins when his own words win over the model's.
|
||||||
|
func TestEntityReferenceText(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
utterance string
|
||||||
|
text string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "the model transliterated the name",
|
||||||
|
utterance: "перезапусти muzick indexer",
|
||||||
|
text: "перезагрузить музик индексер",
|
||||||
|
want: "muzick indexer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "it kept the name, so nothing to repair",
|
||||||
|
utterance: "перезапусти muzick indexer",
|
||||||
|
text: "перезагрузить muzick indexer",
|
||||||
|
want: "перезагрузить muzick indexer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an all-Russian entity name is not a rewrite",
|
||||||
|
utterance: "перезапусти домашний сервер",
|
||||||
|
text: "перезагрузить домашний сервер",
|
||||||
|
want: "перезагрузить домашний сервер",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an English turn never enters the recovery",
|
||||||
|
utterance: "restart muzick indexer",
|
||||||
|
text: "restart muzick indexer",
|
||||||
|
want: "restart muzick indexer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the longest Latin run is the name",
|
||||||
|
utterance: "а перезапусти-ка nginx на muzick-indexer, пожалуйста",
|
||||||
|
text: "перезагрузить нгинкс",
|
||||||
|
want: "muzick-indexer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one stray letter is not a name",
|
||||||
|
utterance: "перезапусти сервер a",
|
||||||
|
text: "перезагрузить сервер",
|
||||||
|
want: "перезагрузить сервер",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dec := router.Decision{Utterance: tc.utterance, Slots: router.Slots{Text: tc.text}}
|
||||||
|
if got := entityReferenceText(dec); got != tc.want {
|
||||||
|
t.Fatalf("entityReferenceText = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNexusIsAskedForTheNameHeSaid — the defect end to end (Vikunja #476): the
|
||||||
|
// router hands over a transliterated Text, and Nexus must still be asked about
|
||||||
|
// the service that exists.
|
||||||
|
func TestNexusIsAskedForTheNameHeSaid(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||||
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти muzick indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Slots: router.Slots{Text: "перезагрузить музик индексер", Fn: "restart", HasFn: true},
|
||||||
|
}
|
||||||
|
h.handleHexisAct(ctx, dec)
|
||||||
|
|
||||||
|
reqs := nexus.Requests()
|
||||||
|
if len(reqs) == 0 {
|
||||||
|
t.Fatal("nexus was never asked")
|
||||||
|
}
|
||||||
|
body := string(reqs[0].Body)
|
||||||
|
if !strings.Contains(body, "muzick indexer") {
|
||||||
|
t.Fatalf("nexus resolve body = %s, want the name he said", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnEntityActReachesHexisInsteadOfAsking — the second half of #476. The
|
||||||
|
// stage-3 gate thins an act with no allowlisted fn, and that question used to
|
||||||
|
// be the whole turn, so the Hexis path was unreachable from voice or chat.
|
||||||
|
func TestAnEntityActReachesHexisInsteadOfAsking(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||||
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти muzick indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Stage: 3,
|
||||||
|
Clarify: true,
|
||||||
|
Slots: router.Slots{Text: "restart status muzick indexer"},
|
||||||
|
}
|
||||||
|
reply := h.hexisBeforeClarify(ctx, dec)
|
||||||
|
if reply == "" {
|
||||||
|
t.Fatal("a resolvable entity act must reach hexis rather than fall through to the question")
|
||||||
|
}
|
||||||
|
if hexis.Count("", "/api/v1") == 0 {
|
||||||
|
t.Fatal("hexis was never contacted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClarifyStillAsksWithoutHexis — the narrowing. No ecosystem, no change:
|
||||||
|
// she asks exactly what she asked before.
|
||||||
|
func TestClarifyStillAsksWithoutHexis(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти muzick indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Stage: 3,
|
||||||
|
Clarify: true,
|
||||||
|
Slots: router.Slots{Text: "перезагрузить музик индексер"},
|
||||||
|
}
|
||||||
|
if reply := h.hexisBeforeClarify(context.Background(), dec); reply != "" {
|
||||||
|
t.Fatalf("no hexis must mean no reply, got %q", reply)
|
||||||
|
}
|
||||||
|
if _, asked := h.askClarify(voiceCtx(), dec); !asked {
|
||||||
|
t.Fatal("she must still ask what to do")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -584,7 +584,7 @@ func TestDigestSev4BypassesQueue(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := refNow()
|
now := refNow()
|
||||||
markPresent(t, st, ctx, now)
|
markPresent(t, st, ctx, now)
|
||||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down", "poll:uptimekuma", "down", now); err != nil {
|
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", "down", now); err != nil {
|
||||||
t.Fatalf("seed service_down: %v", err)
|
t.Fatalf("seed service_down: %v", err)
|
||||||
}
|
}
|
||||||
sink := &fakeSink{}
|
sink := &fakeSink{}
|
||||||
|
|||||||
@@ -351,6 +351,9 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
|||||||
// and park the request (clarify.go); otherwise the replier's canned reply
|
// and park the request (clarify.go); otherwise the replier's canned reply
|
||||||
// stands.
|
// stands.
|
||||||
if dec.Clarify {
|
if dec.Clarify {
|
||||||
|
if reply := h.hexisBeforeClarify(ctx, dec); reply != "" {
|
||||||
|
return withNotice(expiredNotice, reply)
|
||||||
|
}
|
||||||
if question, asked := h.askClarify(ctx, dec); asked {
|
if question, asked := h.askClarify(ctx, dec); asked {
|
||||||
return withNotice(expiredNotice, question)
|
return withNotice(expiredNotice, question)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -238,7 +238,10 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
|
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
|
||||||
// Store-backed when the daemon passes a store, so a restart mid-conversation
|
// Store-backed when the daemon passes a store, so a restart mid-conversation
|
||||||
// keeps the thread (Vikunja #363). Sessions past their TTL are dropped on
|
// keeps the thread (Vikunja #363). Sessions past their TTL are dropped on
|
||||||
// load, never revived. Clarify's parked question stays in memory only.
|
// load, never revived. Clarify's parked question stays in memory only, and
|
||||||
|
// that is a decision rather than an omission (Vikunja #385, docs/design.md):
|
||||||
|
// a restart expires it, so the thread comes back and the open question does
|
||||||
|
// not.
|
||||||
var dialogueSessions *dialogue.SessionStore
|
var dialogueSessions *dialogue.SessionStore
|
||||||
if dataStore != nil {
|
if dataStore != nil {
|
||||||
dialogueSessions = dialogue.NewPersistentSessionStore(2*time.Minute, dataStore)
|
dialogueSessions = dialogue.NewPersistentSessionStore(2*time.Minute, dataStore)
|
||||||
|
|||||||
+71
-21
@@ -140,6 +140,10 @@ type poller struct {
|
|||||||
wgIface string
|
wgIface string
|
||||||
wgCmd string
|
wgCmd string
|
||||||
|
|
||||||
|
// kumaSeen — monitor name → state as of the last poll, so a monitor that
|
||||||
|
// disappears from the gauge can be marked unknown instead of staying down.
|
||||||
|
kumaSeen map[string]string
|
||||||
|
|
||||||
// zen is nil unless a token file was configured — money tracking is a
|
// zen is nil unless a token file was configured — money tracking is a
|
||||||
// capability, off by default like weather and telegram.
|
// capability, off by default like weather and telegram.
|
||||||
zen *zenmoney.Client
|
zen *zenmoney.Client
|
||||||
@@ -333,50 +337,96 @@ func maxSeverity(a netdataAlarms) string {
|
|||||||
return sev
|
return sev
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- kuma: monitor_status gauge → aggregate service_down -------------------
|
// ---- kuma: monitor_status gauge → one fact per monitor ---------------------
|
||||||
|
|
||||||
// Kuma exposes Prometheus text: `monitor_status{...,monitor_name="X"} V` where
|
// Kuma exposes Prometheus text: `monitor_status{...,monitor_name="X"} V` where
|
||||||
// V is 1=up 0=down 2=pending 3=maintenance. We reduce to one aggregate the
|
// V is 1=up 0=down 2=pending 3=maintenance. We write one fact per monitor,
|
||||||
// existing ServiceDownRule consumes: "down" if ANY monitor reads 0, else "up".
|
// keyed `service_down:<monitor name>`, because the nudge has to say WHICH
|
||||||
// Per-service granularity is a later add (a fact per monitor) — the MVP nudge
|
// service is down. The aggregate this used to write could not, which is why
|
||||||
// only needs "something is down".
|
// the rule shipped disabled.
|
||||||
var kumaLine = regexp.MustCompile(`^monitor_status\{([^}]*)\}\s+([0-9.eE+-]+)`)
|
var (
|
||||||
|
kumaLine = regexp.MustCompile(`^monitor_status\{([^}]*)\}\s+([0-9.eE+-]+)`)
|
||||||
|
kumaName = regexp.MustCompile(`monitor_name="([^"]*)"`)
|
||||||
|
)
|
||||||
|
|
||||||
func (p *poller) pollKuma(ctx context.Context, now time.Time) error {
|
func (p *poller) pollKuma(ctx context.Context, now time.Time) error {
|
||||||
body, err := p.get(ctx, p.kumaURL, p.kumaKey)
|
body, err := p.get(ctx, p.kumaURL, p.kumaKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
down, seen := kumaAnyDown(body)
|
states := kumaMonitors(body)
|
||||||
if !seen {
|
if len(states) == 0 {
|
||||||
return fmt.Errorf("no monitor_status metrics (auth/endpoint wrong?)")
|
return fmt.Errorf("no monitor_status metrics (auth/endpoint wrong?)")
|
||||||
}
|
}
|
||||||
val := "up"
|
var firstErr error
|
||||||
if down {
|
for name, val := range states {
|
||||||
val = "down"
|
if err := p.writeIfChanged(ctx, kumaFactKey(name), kumaSource, val, now); err != nil && firstErr == nil {
|
||||||
|
firstErr = err // one bad monitor must not blind the rest
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return p.writeIfChanged(ctx, "service_down", "poll:uptimekuma", val, now)
|
// A monitor deleted in kuma stops appearing in the gauge, and its last fact
|
||||||
|
// would otherwise read "down" forever. Mark it unknown, which no rule fires
|
||||||
|
// on. The seen-set is in memory, so a restart forgets it — harmless, since
|
||||||
|
// the next poll that still lacks the monitor says nothing new either.
|
||||||
|
for name := range p.kumaSeen {
|
||||||
|
if _, still := states[name]; !still {
|
||||||
|
if err := p.writeIfChanged(ctx, kumaFactKey(name), kumaSource, "unknown", now); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.kumaSeen = states
|
||||||
|
return firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// kumaAnyDown parses kuma's Prometheus text: down=true if any monitor reads 0
|
// kumaSource — the provenance the loop rule requires. Written here, checked in
|
||||||
// (pending=2/maintenance=3 are not "down"). seen=false ⇒ no monitor_status
|
// loop.ServiceDownRule; a poller under any other source cannot fire it.
|
||||||
// lines matched at all (wrong endpoint or auth rejected before the body).
|
const kumaSource = "poll:uptimekuma"
|
||||||
func kumaAnyDown(body []byte) (down, seen bool) {
|
|
||||||
|
// kumaFactKey — the fact key for one monitor. The suffix is the name he hears,
|
||||||
|
// so it stays as kuma spells it rather than being slugged into something else.
|
||||||
|
func kumaFactKey(name string) string { return "service_down:" + name }
|
||||||
|
|
||||||
|
// kumaMonitors parses kuma's Prometheus text into monitor name → state
|
||||||
|
// ("up"/"down"/"pending"/"maintenance"). An empty map means no monitor_status
|
||||||
|
// line matched at all (wrong endpoint, or auth rejected before the body).
|
||||||
|
// A line with no monitor_name label is skipped: a fact nobody can name is
|
||||||
|
// exactly the thing this replaced.
|
||||||
|
func kumaMonitors(body []byte) map[string]string {
|
||||||
|
out := make(map[string]string)
|
||||||
for _, line := range strings.Split(string(body), "\n") {
|
for _, line := range strings.Split(string(body), "\n") {
|
||||||
m := kumaLine.FindStringSubmatch(strings.TrimSpace(line))
|
m := kumaLine.FindStringSubmatch(strings.TrimSpace(line))
|
||||||
if m == nil {
|
if m == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen = true
|
nm := kumaName.FindStringSubmatch(m[1])
|
||||||
|
if nm == nil || strings.TrimSpace(nm[1]) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
v, err := strconv.ParseFloat(m[2], 64)
|
v, err := strconv.ParseFloat(m[2], 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if v == 0 {
|
out[strings.TrimSpace(nm[1])] = kumaState(v)
|
||||||
down = true
|
}
|
||||||
}
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// kumaState — the gauge's four values. pending and maintenance are not "down":
|
||||||
|
// a monitor paused in kuma should silence that monitor, not page him.
|
||||||
|
func kumaState(v float64) string {
|
||||||
|
switch v {
|
||||||
|
case 0:
|
||||||
|
return "down"
|
||||||
|
case 1:
|
||||||
|
return "up"
|
||||||
|
case 2:
|
||||||
|
return "pending"
|
||||||
|
case 3:
|
||||||
|
return "maintenance"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
}
|
}
|
||||||
return down, seen
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- helpers ---------------------------------------------------------------
|
// ---- helpers ---------------------------------------------------------------
|
||||||
|
|||||||
+36
-12
@@ -35,22 +35,46 @@ func TestMaxSeverity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKumaAnyDown(t *testing.T) {
|
func TestKumaMonitorsNamesEveryOne(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
body string
|
name string
|
||||||
down, seen bool
|
body string
|
||||||
|
want map[string]string
|
||||||
}{
|
}{
|
||||||
{"", false, false},
|
{"empty body", "", map[string]string{}},
|
||||||
{`monitor_status{monitor_name="web"} 1`, false, true},
|
{"help line only", `# HELP monitor_status ...`, map[string]string{}},
|
||||||
{`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`, true, true},
|
{
|
||||||
{`monitor_status{monitor_name="mnt"} 3`, false, true}, // maintenance ≠ down
|
"one up one down",
|
||||||
{`# HELP monitor_status ...`, false, false},
|
`monitor_status{monitor_name="web"} 1` + "\n" + `monitor_status{monitor_name="db"} 0`,
|
||||||
|
map[string]string{"web": "up", "db": "down"},
|
||||||
|
},
|
||||||
|
{"maintenance is not down", `monitor_status{monitor_name="mnt"} 3`, map[string]string{"mnt": "maintenance"}},
|
||||||
|
{"pending is not down", `monitor_status{monitor_name="p"} 2`, map[string]string{"p": "pending"}},
|
||||||
|
{
|
||||||
|
"other labels do not hide the name",
|
||||||
|
`monitor_status{monitor_type="http",monitor_name="ci",monitor_url="x"} 0`,
|
||||||
|
map[string]string{"ci": "down"},
|
||||||
|
},
|
||||||
|
{"a nameless line is skipped", `monitor_status{monitor_type="http"} 0`, map[string]string{}},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
down, seen := kumaAnyDown([]byte(c.body))
|
t.Run(c.name, func(t *testing.T) {
|
||||||
if down != c.down || seen != c.seen {
|
got := kumaMonitors([]byte(c.body))
|
||||||
t.Errorf("kumaAnyDown(%q) = (%v,%v), want (%v,%v)", c.body, down, seen, c.down, c.seen)
|
if len(got) != len(c.want) {
|
||||||
}
|
t.Fatalf("kumaMonitors = %v, want %v", got, c.want)
|
||||||
|
}
|
||||||
|
for k, v := range c.want {
|
||||||
|
if got[k] != v {
|
||||||
|
t.Errorf("monitor %q = %q, want %q", k, got[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKumaFactKeyCarriesTheName(t *testing.T) {
|
||||||
|
if got := kumaFactKey("nexus db"); got != "service_down:nexus db" {
|
||||||
|
t.Errorf("kumaFactKey = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -8,12 +8,12 @@
|
|||||||
"//disabled_rules": [
|
"//disabled_rules": [
|
||||||
"Nudge rules that are not wired at all. Names come from loop.DefaultRules:",
|
"Nudge rules that are not wired at all. Names come from loop.DefaultRules:",
|
||||||
"water, meal, break, service_down, netdata_critical.",
|
"water, meal, break, service_down, netdata_critical.",
|
||||||
"service_down is off because it cannot say WHICH service — mavpoll folds the",
|
"service_down is back on: mavpoll now writes one fact per kuma monitor",
|
||||||
"whole kuma gauge into one boolean, so the nudge is always the generic 'a",
|
"(service_down:<name>), so the nudge names the service and pausing a monitor",
|
||||||
"service on homesrv is down'. Nothing to act on, every fifteen minutes.",
|
"in kuma silences that monitor. It is also edge-triggered, so a service that",
|
||||||
"Turn it back on once Vikunja #444 lands a fact per monitor."
|
"stays down is one nudge, not one every fifteen minutes."
|
||||||
],
|
],
|
||||||
"disabled_rules": ["service_down"],
|
"disabled_rules": [],
|
||||||
|
|
||||||
"phraser": {
|
"phraser": {
|
||||||
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
|
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
|
||||||
|
|||||||
@@ -223,6 +223,30 @@ Not alternatives — layers:
|
|||||||
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
|
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
|
||||||
7 intents (`fact, reminder, note, query, act, chat, system`).
|
7 intents (`fact, reminder, note, query, act, chat, system`).
|
||||||
|
|
||||||
|
#### A restart expires a parked question
|
||||||
|
|
||||||
|
Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a
|
||||||
|
restart; the clarify question parked behind it does not, and neither do the
|
||||||
|
three yes/no confirms in `voice.go`. `ClarifyStore` stays in memory.
|
||||||
|
|
||||||
|
Three reasons, in the order they settle it:
|
||||||
|
|
||||||
|
- The clock stops meaning anything. A parked question carries a 90s TTL and an
|
||||||
|
attempt count. A restart is a gap of unknown length, so a restored question is
|
||||||
|
either already dead or pretending to be young.
|
||||||
|
- Restoring the question restores the request behind it. He asked for something,
|
||||||
|
she asked back, and then the daemon went away. Acting on that minutes later,
|
||||||
|
against words he has probably given up on, is the misroute the stage 3 gate
|
||||||
|
exists to avoid.
|
||||||
|
- She does not announce it either. The expiry notice needs to know a question
|
||||||
|
was parked, and knowing that across a restart means storing it. One sentence,
|
||||||
|
in the rare window where he speaks within 90s of a restart, does not pay for a
|
||||||
|
marker that outlives the thing it describes. His next words route fresh, which
|
||||||
|
is the correct answer with or without the notice.
|
||||||
|
|
||||||
|
So the notice stays what it is: the in-process TTL case, where she really did
|
||||||
|
wait and really did let go.
|
||||||
|
|
||||||
### save-where — the two-memory routing axis
|
### save-where — the two-memory routing axis
|
||||||
|
|
||||||
One discriminator: **does the loop evaluate a predicate against it?**
|
One discriminator: **does the loop evaluate a predicate against it?**
|
||||||
@@ -365,6 +389,29 @@ lives in `source`; rules trust provenance.
|
|||||||
`source=poll:healthcheck`. A compromised poller must not be able to forge a
|
`source=poll:healthcheck`. A compromised poller must not be able to forge a
|
||||||
trigger.
|
trigger.
|
||||||
|
|
||||||
|
#### A fact per monitor, not an aggregate
|
||||||
|
|
||||||
|
mavpoll writes one fact per kuma monitor, keyed `service_down:<monitor name>`.
|
||||||
|
It used to fold the whole gauge into a single boolean, and the nudge could then
|
||||||
|
only say that something on homesrv was down. That is not something he can act
|
||||||
|
on, so the rule shipped disabled.
|
||||||
|
|
||||||
|
Three things follow from the split:
|
||||||
|
|
||||||
|
- The key set is no longer known at wiring time. A rule declares
|
||||||
|
`WantPrefixes` and the gatherer resolves the family per tick, which is the
|
||||||
|
only prefix read in the loop.
|
||||||
|
- Pausing a monitor in kuma silences that monitor. Under the aggregate it
|
||||||
|
silenced nothing, because some other monitor kept the boolean at "down".
|
||||||
|
- A monitor deleted in kuma would keep its last fact reading "down" forever, so
|
||||||
|
mavpoll marks a vanished monitor "unknown". No rule fires on "unknown".
|
||||||
|
|
||||||
|
The rule is also edge-triggered: it fires on a transition it has not already
|
||||||
|
nudged about (`State.NudgedSince`). A polled fact is written only when the
|
||||||
|
value changes, but the predicate reads the current value, so without the edge
|
||||||
|
check a service that stays down qualifies on every tick and cooldown is the
|
||||||
|
only brake.
|
||||||
|
|
||||||
### Presence — concrete scoring
|
### Presence — concrete scoring
|
||||||
|
|
||||||
**Combiner — noisy-OR, not weighted sum.** These are independent-ish positive
|
**Combiner — noisy-OR, not weighted sum.** These are independent-ish positive
|
||||||
|
|||||||
@@ -149,10 +149,11 @@ type Config struct {
|
|||||||
//
|
//
|
||||||
// Rules are code, not config (see loop.DefaultRules), and that stays true:
|
// Rules are code, not config (see loop.DefaultRules), and that stays true:
|
||||||
// this only subtracts. It exists because a rule can be right in principle
|
// this only subtracts. It exists because a rule can be right in principle
|
||||||
// and useless in practice — kuma's service_down cannot name the service it
|
// and useless in practice. service_down was the case that forced it: it
|
||||||
// is nudging about (Vikunja #444), so being told "a service on homesrv is
|
// could not name the service it was nudging about, so being told "a service
|
||||||
// down" every fifteen minutes is noise with no action attached. Turning it
|
// on homesrv is down" every fifteen minutes was noise with no action
|
||||||
// off beats learning to ignore her.
|
// attached. That is fixed — one fact per kuma monitor — and the rule ships
|
||||||
|
// enabled again. The escape hatch stays.
|
||||||
//
|
//
|
||||||
// A disabled rule is never gathered for, never evaluated, and never
|
// A disabled rule is never gathered for, never evaluated, and never
|
||||||
// delivered on any channel. Unknown names are ignored, so removing a rule
|
// delivered on any channel. Unknown names are ignored, so removing a rule
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ func (q *PendingQuestion) CanAsk() bool {
|
|||||||
|
|
||||||
// ClarifyStore holds the parked questions. Same shape and locking as
|
// ClarifyStore holds the parked questions. Same shape and locking as
|
||||||
// SessionStore: keyed by dialogue id, expired entries dropped on read.
|
// SessionStore: keyed by dialogue id, expired entries dropped on read.
|
||||||
|
//
|
||||||
|
// Memory only, deliberately, unlike SessionStore — a restart expires every
|
||||||
|
// parked question and she does not announce that it happened (Vikunja #385,
|
||||||
|
// written down in docs/design.md). The 90s TTL and the attempt count measure a
|
||||||
|
// pause in one conversation, and a restart is a gap of unknown length, so a
|
||||||
|
// restored question would either be dead already or lying about its age. His
|
||||||
|
// next words route fresh, which is the right answer with or without a notice.
|
||||||
|
// Do not give this store a persister without re-arguing that.
|
||||||
type ClarifyStore struct {
|
type ClarifyStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
questions map[string]*PendingQuestion
|
questions map[string]*PendingQuestion
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ func TestExplainGate_PresenceAway(t *testing.T) {
|
|||||||
func TestExplainGate_PresenceAwayOpsBypass(t *testing.T) {
|
func TestExplainGate_PresenceAwayOpsBypass(t *testing.T) {
|
||||||
now := refTime()
|
now := refTime()
|
||||||
s := State{Now: now, Presence: store.Away,
|
s := State{Now: now, Presence: store.Away,
|
||||||
Facts: map[string]store.Fact{"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute))},
|
Facts: map[string]store.Fact{"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute))},
|
||||||
}
|
}
|
||||||
r := ServiceDownRule() // Sev4 ops
|
r := ServiceDownRule() // Sev4 ops
|
||||||
passed, blocked, d := ExplainGate(s, r)
|
passed, blocked, d := ExplainGate(s, r)
|
||||||
@@ -259,8 +259,8 @@ func TestExplainTick_WinnerRecorded(t *testing.T) {
|
|||||||
Now: now,
|
Now: now,
|
||||||
Presence: store.Present,
|
Presence: store.Present,
|
||||||
Facts: map[string]store.Fact{
|
Facts: map[string]store.Fact{
|
||||||
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
||||||
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cand, trace := ExplainTick(s, DefaultRules())
|
cand, trace := ExplainTick(s, DefaultRules())
|
||||||
|
|||||||
@@ -198,12 +198,12 @@ func TestTickNeverDogpilesAndPicksLoudest(t *testing.T) {
|
|||||||
Now: now,
|
Now: now,
|
||||||
Presence: store.Present,
|
Presence: store.Present,
|
||||||
Facts: map[string]store.Fact{
|
Facts: map[string]store.Fact{
|
||||||
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
|
"water": ago("water", "tap:water", `"250ml"`, 5*time.Hour),
|
||||||
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
|
"meal": ago("meal", "voice", `"lunch"`, 8*time.Hour),
|
||||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||||
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
|
"break": ago("break", "voice", `"walk"`, 3*time.Hour),
|
||||||
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
|
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute),
|
||||||
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
|
"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
// sanity: every rule really does want to fire, so the pick is a real choice.
|
// sanity: every rule really does want to fire, so the pick is a real choice.
|
||||||
|
|||||||
@@ -91,6 +91,20 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto
|
|||||||
return State{}, nil, err
|
return State{}, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prefix families — the keys a rule cannot name at wiring time (one fact
|
||||||
|
// per kuma monitor). Loaded into the same map; State.FactsUnder reads them.
|
||||||
|
for _, r := range g.rules {
|
||||||
|
for _, p := range r.WantPrefixes {
|
||||||
|
fam, err := g.store.LatestFactsByPrefix(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return State{}, nil, err
|
||||||
|
}
|
||||||
|
for _, f := range fam {
|
||||||
|
facts[f.Key] = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// last nudge per rule + cooldown-until derived from the active cooldown.
|
// last nudge per rule + cooldown-until derived from the active cooldown.
|
||||||
// "active" = the feedback tuner's persisted base if one exists, else the
|
// "active" = the feedback tuner's persisted base if one exists, else the
|
||||||
// rule's static Base. LatestFactBySource is the trust-by-provenance read
|
// rule's static Base. LatestFactBySource is the trust-by-provenance read
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package loop
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The gatherer is the only impure piece, and a rule over a prefix has no keys
|
||||||
|
// to declare at wiring time. This is the end of that path: mavpoll's per-monitor
|
||||||
|
// facts reach the snapshot, and the rule fires on the one that is down.
|
||||||
|
func TestGatherStateLoadsPrefixFamilies(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s, err := store.Open(ctx, filepath.Join(t.TempDir(), "loop_test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
|
||||||
|
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||||
|
for key, val := range map[string]string{
|
||||||
|
"service_down:db": "down",
|
||||||
|
"service_down:web": "up",
|
||||||
|
} {
|
||||||
|
if _, err := s.SetValue(ctx, store.KindEnv, key, ServiceDownSource, val, now.Add(-time.Minute)); err != nil {
|
||||||
|
t.Fatalf("SetValue %s: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rules := []Rule{ServiceDownRule()}
|
||||||
|
st, _, err := NewGatherer(s, rules).GatherState(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GatherState: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := st.Facts["service_down:db"]; !ok {
|
||||||
|
t.Fatalf("prefix family not gathered: %v", st.Facts)
|
||||||
|
}
|
||||||
|
if got := DownServices(st); len(got) != 1 || got[0] != "db" {
|
||||||
|
t.Fatalf("DownServices = %v, want [db]", got)
|
||||||
|
}
|
||||||
|
if !rules[0].Predicate(st) {
|
||||||
|
t.Fatal("the rule must fire on a gathered per-monitor fact")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ func TestTickOpsHardSurvivesAwayAndQuiet(t *testing.T) {
|
|||||||
Presence: store.Away,
|
Presence: store.Away,
|
||||||
QuietHours: true,
|
QuietHours: true,
|
||||||
Facts: map[string]store.Fact{
|
Facts: map[string]store.Fact{
|
||||||
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
got := Tick(s, DefaultRules())
|
got := Tick(s, DefaultRules())
|
||||||
@@ -99,7 +99,7 @@ func TestTickServiceSourceTrustRefusesForgedTrigger(t *testing.T) {
|
|||||||
Now: now,
|
Now: now,
|
||||||
Presence: store.Present,
|
Presence: store.Present,
|
||||||
Facts: map[string]store.Fact{
|
Facts: map[string]store.Fact{
|
||||||
"service_down": factAt("service_down", "ambient", `"down"`, now.Add(-1*time.Minute)),
|
"service_down:db": factAt("service_down:db", "ambient", `"down"`, now.Add(-1*time.Minute)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if got := Tick(s, DefaultRules()); got != nil {
|
if got := Tick(s, DefaultRules()); got != nil {
|
||||||
@@ -115,8 +115,8 @@ func TestTickOneNudgePerTickMaxSeverityWins(t *testing.T) {
|
|||||||
Now: now,
|
Now: now,
|
||||||
Presence: store.Present,
|
Presence: store.Present,
|
||||||
Facts: map[string]store.Fact{
|
Facts: map[string]store.Fact{
|
||||||
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
"water": factAt("water", "tap:water", `"250ml"`, now.Add(-4*time.Hour)),
|
||||||
"service_down": factAt("service_down", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
"service_down:db": factAt("service_down:db", "poll:uptimekuma", `"down"`, now.Add(-1*time.Minute)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
got := Tick(s, DefaultRules())
|
got := Tick(s, DefaultRules())
|
||||||
|
|||||||
+55
-13
@@ -28,6 +28,13 @@ type Rule struct {
|
|||||||
// that check itself, leave this empty. Otherwise set to the key(s) the rule
|
// that check itself, leave this empty. Otherwise set to the key(s) the rule
|
||||||
// needs and the gate will skip the rule when any are missing.
|
// needs and the gate will skip the rule when any are missing.
|
||||||
InertWhenNoData []string
|
InertWhenNoData []string
|
||||||
|
|
||||||
|
// WantPrefixes — key prefixes whose whole family the gatherer must load.
|
||||||
|
// InertWhenNoData names keys that exist at wiring time; a rule over a key
|
||||||
|
// set that is only known at read time (one fact per kuma monitor) declares
|
||||||
|
// the prefix here instead. Prefixes never make a rule inert: an empty
|
||||||
|
// family is the predicate's own "no data" case.
|
||||||
|
WantPrefixes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cooldown — tunable bounded by the envelope so a weird week (auto-tuned) can't
|
// Cooldown — tunable bounded by the envelope so a weird week (auto-tuned) can't
|
||||||
@@ -98,23 +105,58 @@ func BreakRule() Rule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServiceDownRule — sev4 ops hard: the `service_down` aggregate fact reads
|
// ServiceDownPrefix — mavpoll writes one fact per kuma monitor under this
|
||||||
// "down". Source must be poll:uptimekuma — kuma is the source of truth for
|
// prefix, `service_down:<monitor name>`. The suffix is the name he hears.
|
||||||
// service up/down (mavpoll writes this key). The predicate is provenance-scoped:
|
const ServiceDownPrefix = "service_down:"
|
||||||
// a compromised poller writing under a different source can't forge the trigger.
|
|
||||||
|
// ServiceDownSource — kuma is the source of truth for service up/down. The
|
||||||
|
// rule is provenance-scoped: a poller writing under a different source cannot
|
||||||
|
// forge the trigger.
|
||||||
|
const ServiceDownSource = "poll:uptimekuma"
|
||||||
|
|
||||||
|
// DownServices — the monitors currently reading "down", by name, in key order.
|
||||||
|
//
|
||||||
|
// Pure, and the rule and the phraser both call it, so the message can never
|
||||||
|
// name a service the predicate did not fire on.
|
||||||
|
func DownServices(s State) []string {
|
||||||
|
var out []string
|
||||||
|
for _, f := range s.FactsUnder(ServiceDownPrefix) {
|
||||||
|
if f.Source == ServiceDownSource && f.Value == `"down"` {
|
||||||
|
out = append(out, strings.TrimPrefix(f.Key, ServiceDownPrefix))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceDownRule — sev4 ops hard: at least one kuma monitor reads "down".
|
||||||
|
//
|
||||||
|
// It used to read one aggregate `service_down` fact, which is why it was
|
||||||
|
// disabled in deploy: the nudge could say that something on homesrv was down
|
||||||
|
// but never which thing. Per-monitor facts fix that, and pausing a monitor in
|
||||||
|
// kuma now silences that monitor rather than nothing.
|
||||||
|
//
|
||||||
|
// Edge-triggered — see State.NudgedSince. Without it a service that stays down
|
||||||
|
// for a day qualifies on every tick and cooldown alone is the only brake.
|
||||||
func ServiceDownRule() Rule {
|
func ServiceDownRule() Rule {
|
||||||
return Rule{
|
return Rule{
|
||||||
Name: "service_down",
|
Name: "service_down",
|
||||||
Severity: Sev4,
|
Severity: Sev4,
|
||||||
Cooldown: Cooldown{Base: 15 * time.Minute, Min: 5 * time.Minute, Max: 1 * time.Hour},
|
Cooldown: Cooldown{Base: 15 * time.Minute, Min: 5 * time.Minute, Max: 1 * time.Hour},
|
||||||
InertWhenNoData: []string{"service_down"},
|
WantPrefixes: []string{ServiceDownPrefix},
|
||||||
Predicate: func(s State) bool {
|
Predicate: func(s State) bool {
|
||||||
f, ok := s.Fact("service_down")
|
var newest time.Time
|
||||||
if !ok || f.Ts.IsZero() {
|
for _, f := range s.FactsUnder(ServiceDownPrefix) {
|
||||||
return false
|
if f.Source != ServiceDownSource || f.Value != `"down"` {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if f.Ts.After(newest) {
|
||||||
|
newest = f.Ts
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// value is json `"down"`; trivial check keyed off source provenance.
|
if newest.IsZero() {
|
||||||
return f.Source == "poll:uptimekuma" && f.Value == `"down"`
|
return false // nothing down, or no data at all → shut up
|
||||||
|
}
|
||||||
|
return !s.NudgedSince("service_down", newest)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-15
@@ -193,13 +193,13 @@ func TestOpsRulePredicates(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "service_down fires on a kuma down fact",
|
name: "service_down fires on a kuma down fact",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute)},
|
||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "service_down quiet when kuma says up",
|
name: "service_down quiet when kuma says up",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"up"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `"up"`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -211,38 +211,38 @@ func TestOpsRulePredicates(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "service_down quiet on a zero-timestamp fact",
|
name: "service_down quiet on a zero-timestamp fact",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down": {Key: "service_down", Source: "poll:uptimekuma", Value: `"down"`}},
|
facts: map[string]store.Fact{"service_down:db": {Key: "service_down:db", Source: "poll:uptimekuma", Value: `"down"`}},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
// forgery attempts — right value, wrong writer.
|
// forgery attempts — right value, wrong writer.
|
||||||
{
|
{
|
||||||
name: "service_down refuses a forgery from the netdata poller",
|
name: "service_down refuses a forgery from the netdata poller",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:netdata", `"down"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:netdata", `"down"`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "service_down refuses a forgery from ambient audio",
|
name: "service_down refuses a forgery from ambient audio",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down": ago("service_down", "ambient:other", `"down"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "ambient:other", `"down"`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "service_down refuses a forgery from the user's own voice",
|
name: "service_down refuses a forgery from the user's own voice",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down": ago("service_down", "voice", `"down"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "voice", `"down"`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "service_down refuses a source that only looks like kuma",
|
name: "service_down refuses a source that only looks like kuma",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma-staging", `"down"`, time.Minute)},
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma-staging", `"down"`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "service_down refuses an unquoted down value",
|
name: "service_down refuses an unquoted down value",
|
||||||
rule: ServiceDownRule(),
|
rule: ServiceDownRule(),
|
||||||
facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `down`, time.Minute)},
|
facts: map[string]store.Fact{"service_down:db": ago("service_down:db", "poll:uptimekuma", `down`, time.Minute)},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -312,9 +312,11 @@ func TestOpsRulePredicates(t *testing.T) {
|
|||||||
// a second no-data backstop, so a rule that forgets it loses the safety net
|
// a second no-data backstop, so a rule that forgets it loses the safety net
|
||||||
// even if its predicate happens to check.
|
// even if its predicate happens to check.
|
||||||
func TestDefaultRulesDeclareInertKeys(t *testing.T) {
|
func TestDefaultRulesDeclareInertKeys(t *testing.T) {
|
||||||
|
// A rule over a key set that only exists at read time declares a prefix
|
||||||
|
// instead — the gatherer still needs to be told what to load.
|
||||||
for _, r := range DefaultRules() {
|
for _, r := range DefaultRules() {
|
||||||
if len(r.InertWhenNoData) == 0 {
|
if len(r.InertWhenNoData) == 0 && len(r.WantPrefixes) == 0 {
|
||||||
t.Errorf("rule %q declares no InertWhenNoData keys", r.Name)
|
t.Errorf("rule %q declares neither InertWhenNoData keys nor WantPrefixes", r.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,11 +379,11 @@ func TestDefaultRuleCooldownsAreBounded(t *testing.T) {
|
|||||||
// hidden state, no clock reads.
|
// hidden state, no clock reads.
|
||||||
func TestPredicatesArePure(t *testing.T) {
|
func TestPredicatesArePure(t *testing.T) {
|
||||||
s := stateWith(map[string]store.Fact{
|
s := stateWith(map[string]store.Fact{
|
||||||
"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour),
|
"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour),
|
||||||
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
|
"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour),
|
||||||
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
"desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second),
|
||||||
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
"break": ago("break", "voice", `"walk"`, 2*time.Hour),
|
||||||
"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute),
|
"service_down:db": ago("service_down:db", "poll:uptimekuma", `"down"`, time.Minute),
|
||||||
})
|
})
|
||||||
for _, r := range DefaultRules() {
|
for _, r := range DefaultRules() {
|
||||||
first := r.Predicate(s)
|
first := r.Predicate(s)
|
||||||
@@ -434,3 +436,66 @@ func TestRulesExceptEmptyKeepsEverything(t *testing.T) {
|
|||||||
t.Errorf("rules = %v, dropped = %v", ruleNames(rules), dropped)
|
t.Errorf("rules = %v, dropped = %v", ruleNames(rules), dropped)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------- per-monitor service_down -----------------------
|
||||||
|
|
||||||
|
// The rule must name what fired on it, and the phraser reads the same helper,
|
||||||
|
// so a service that is up can never be spoken as down.
|
||||||
|
func TestDownServicesNamesOnlyTheDownOnes(t *testing.T) {
|
||||||
|
s := State{
|
||||||
|
Now: refTime(),
|
||||||
|
Facts: map[string]store.Fact{
|
||||||
|
"service_down:web": ago("service_down:web", ServiceDownSource, `"up"`, time.Minute),
|
||||||
|
"service_down:db": ago("service_down:db", ServiceDownSource, `"down"`, time.Minute),
|
||||||
|
"service_down:vault": ago("service_down:vault", ServiceDownSource, `"down"`, time.Minute),
|
||||||
|
"service_down:paused": ago("service_down:paused", ServiceDownSource, `"maintenance"`, time.Minute),
|
||||||
|
"service_down:forged": ago("service_down:forged", "voice", `"down"`, time.Minute),
|
||||||
|
"service_down:missing": ago("service_down:missing", ServiceDownSource, `"unknown"`, time.Minute),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := DownServices(s)
|
||||||
|
want := []string{"db", "vault"} // key order, so speech is stable
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("DownServices = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("DownServices = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A monitor paused in kuma must silence that monitor. Before per-monitor facts
|
||||||
|
// the aggregate stayed "down" and pausing achieved nothing.
|
||||||
|
func TestPausedMonitorSilencesOnlyItself(t *testing.T) {
|
||||||
|
base := map[string]store.Fact{
|
||||||
|
"service_down:db": ago("service_down:db", ServiceDownSource, `"maintenance"`, time.Minute),
|
||||||
|
"service_down:web": ago("service_down:web", ServiceDownSource, `"down"`, time.Minute),
|
||||||
|
}
|
||||||
|
if !ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) {
|
||||||
|
t.Fatal("web is still down, the rule must fire")
|
||||||
|
}
|
||||||
|
delete(base, "service_down:web")
|
||||||
|
if ServiceDownRule().Predicate(State{Now: refTime(), Facts: base}) {
|
||||||
|
t.Fatal("only a paused monitor is left, the rule must be quiet")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge-triggered: he is told once per transition. A service that stays down
|
||||||
|
// for a day used to qualify on every tick, with cooldown as the only brake.
|
||||||
|
func TestServiceDownFiresOncePerTransition(t *testing.T) {
|
||||||
|
down := ago("service_down:db", ServiceDownSource, `"down"`, time.Hour)
|
||||||
|
s := State{Now: refTime(), Facts: map[string]store.Fact{"service_down:db": down}}
|
||||||
|
if !ServiceDownRule().Predicate(s) {
|
||||||
|
t.Fatal("first sight of the transition must fire")
|
||||||
|
}
|
||||||
|
s.LastNudge = map[string]store.Nudge{"service_down": {Ts: down.Ts.Add(time.Minute)}}
|
||||||
|
if ServiceDownRule().Predicate(s) {
|
||||||
|
t.Fatal("already told about this transition, must be quiet")
|
||||||
|
}
|
||||||
|
// A second service goes down after that nudge — a new edge, so it fires.
|
||||||
|
s.Facts["service_down:web"] = ago("service_down:web", ServiceDownSource, `"down"`, time.Minute)
|
||||||
|
if !ServiceDownRule().Predicate(s) {
|
||||||
|
t.Fatal("a later transition must fire again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
package loop
|
package loop
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -95,6 +97,32 @@ func (s State) Fact(key string) (store.Fact, bool) {
|
|||||||
return f, true
|
return f, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FactsUnder returns every gathered fact whose key starts with prefix, ordered
|
||||||
|
// by key so a caller that names them speaks them in a stable order. Facts with
|
||||||
|
// a zero Ts are skipped, the same "no data" rule Fact applies.
|
||||||
|
func (s State) FactsUnder(prefix string) []store.Fact {
|
||||||
|
var out []store.Fact
|
||||||
|
for k, f := range s.Facts {
|
||||||
|
if strings.HasPrefix(k, prefix) && !f.Ts.IsZero() {
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// NudgedSince reports whether rule already sent a nudge at or after ts.
|
||||||
|
//
|
||||||
|
// It is what makes a rule edge-triggered. A polled fact is written only when
|
||||||
|
// the value changes, so its Ts is the moment the service went down — but the
|
||||||
|
// predicate reads the current value, so a service that stays down keeps
|
||||||
|
// qualifying forever and cooldown alone only slows the repetition. Asking
|
||||||
|
// whether he was already told about THIS transition stops it.
|
||||||
|
func (s State) NudgedSince(rule string, ts time.Time) bool {
|
||||||
|
n, ok := s.LastNudge[rule]
|
||||||
|
return ok && !n.Ts.Before(ts)
|
||||||
|
}
|
||||||
|
|
||||||
// Since returns the duration since the latest fact for key, or (0,false).
|
// Since returns the duration since the latest fact for key, or (0,false).
|
||||||
// "false" ⇒ no data ⇒ shuts up when uncertain.
|
// "false" ⇒ no data ⇒ shuts up when uncertain.
|
||||||
func (s State) Since(key string) (time.Duration, bool) {
|
func (s State) Since(key string) (time.Duration, bool) {
|
||||||
|
|||||||
@@ -975,6 +975,9 @@ var fallbackNudges = map[string]string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func fallbackNudge(c loop.Candidate) string {
|
func fallbackNudge(c loop.Candidate) string {
|
||||||
|
if down := loop.DownServices(c.State); len(down) > 0 {
|
||||||
|
return "Не отвечает: " + strings.Join(down, ", ") + "."
|
||||||
|
}
|
||||||
if s, ok := fallbackNudges[c.Rule.Name]; ok {
|
if s, ok := fallbackNudges[c.Rule.Name]; ok {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -990,6 +993,11 @@ func buildNudgePrompt(c loop.Candidate) string {
|
|||||||
if f, ok := c.State.Facts[c.Rule.Name]; ok && f.Key != "" && f.Key != c.Rule.Name {
|
if f, ok := c.State.Facts[c.Rule.Name]; ok && f.Key != "" && f.Key != c.Rule.Name {
|
||||||
ctxParts = append(ctxParts, "Что именно: "+f.Key)
|
ctxParts = append(ctxParts, "Что именно: "+f.Key)
|
||||||
}
|
}
|
||||||
|
if down := loop.DownServices(c.State); len(down) > 0 {
|
||||||
|
// The names come from the same helper the rule fired on, so the model
|
||||||
|
// is never handed a service that is actually up.
|
||||||
|
ctxParts = append(ctxParts, "Какие сервисы лежат: "+strings.Join(down, ", "))
|
||||||
|
}
|
||||||
if d, ok := c.State.Since(c.Rule.Name); ok {
|
if d, ok := c.State.Since(c.Rule.Name); ok {
|
||||||
ctxParts = append(ctxParts, "Прошло: "+ruDur(d))
|
ctxParts = append(ctxParts, "Прошло: "+ruDur(d))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,16 +142,21 @@ func phraseNudge(c loop.Candidate) (body, summary string) {
|
|||||||
}
|
}
|
||||||
return body, "take a break"
|
return body, "take a break"
|
||||||
case "service_down":
|
case "service_down":
|
||||||
// the fact value is json `"down"`; the key carries the service name.
|
// One fact per kuma monitor, so the nudge names the service. The rule
|
||||||
body = "a service on homesrv is down — check journalctl."
|
// and this share loop.DownServices, so the message cannot name a
|
||||||
summary = "service down on homesrv"
|
// service the predicate did not fire on.
|
||||||
if f, ok := c.State.Fact("service_down"); ok {
|
down := loop.DownServices(c.State)
|
||||||
if f.Key != "" && f.Key != "service_down" {
|
switch len(down) {
|
||||||
body = fmt.Sprintf("%s on homesrv is down — check journalctl.", f.Key)
|
case 0:
|
||||||
summary = fmt.Sprintf("%s down on homesrv", f.Key)
|
return "a service on homesrv is down — check journalctl.", "service down on homesrv"
|
||||||
}
|
case 1:
|
||||||
|
return fmt.Sprintf("%s on homesrv is down — check journalctl.", down[0]),
|
||||||
|
fmt.Sprintf("%s down on homesrv", down[0])
|
||||||
|
default:
|
||||||
|
list := strings.Join(down, ", ")
|
||||||
|
return fmt.Sprintf("%s on homesrv are down — check journalctl.", list),
|
||||||
|
fmt.Sprintf("%d services down on homesrv", len(down))
|
||||||
}
|
}
|
||||||
return body, summary
|
|
||||||
default:
|
default:
|
||||||
// generic: name the rule + severity; the LLM impl replaces this with
|
// generic: name the rule + severity; the LLM impl replaces this with
|
||||||
// a prompted phrase. the Stub never editorializes beyond the rule name.
|
// a prompted phrase. the Stub never editorializes beyond the rule name.
|
||||||
|
|||||||
@@ -78,12 +78,14 @@ func TestPhraseNudgeBreakDeskDuration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPhraseNudgeServiceDownNamedService(t *testing.T) {
|
func TestPhraseNudgeServiceDownNamedService(t *testing.T) {
|
||||||
// a service_down fact whose Key is the specific service name → the phrase
|
// one fact per kuma monitor → the phrase names the monitor that is down.
|
||||||
// names the service, not just "service down".
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
st := loop.State{
|
st := loop.State{
|
||||||
Now: now,
|
Now: now,
|
||||||
Facts: map[string]store.Fact{"service_down": {Key: "nginx", Ts: now, Source: "poll:healthcheck", Value: `"down"`}},
|
Facts: map[string]store.Fact{
|
||||||
|
"service_down:nginx": {Key: "service_down:nginx", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
|
||||||
|
"service_down:db": {Key: "service_down:db", Ts: now, Source: loop.ServiceDownSource, Value: `"up"`},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
|
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
|
||||||
pn, _ := NewStub().PhraseNudge(context.Background(), c)
|
pn, _ := NewStub().PhraseNudge(context.Background(), c)
|
||||||
@@ -96,12 +98,12 @@ func TestPhraseNudgeServiceDownNamedService(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPhraseNudgeServiceDownGenericKey(t *testing.T) {
|
func TestPhraseNudgeServiceDownGenericKey(t *testing.T) {
|
||||||
// the rule key itself ("service_down") rather than a specific service →
|
// the old aggregate key, still in the store from before the per-monitor
|
||||||
// the generic phrase, not a phantom "service_down down on homesrv".
|
// facts landed → the generic phrase, never a phantom "service_down down".
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
st := loop.State{
|
st := loop.State{
|
||||||
Now: now,
|
Now: now,
|
||||||
Facts: map[string]store.Fact{"service_down": {Key: "service_down", Ts: now, Source: "poll:healthcheck", Value: `"down"`}},
|
Facts: map[string]store.Fact{"service_down": {Key: "service_down", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`}},
|
||||||
}
|
}
|
||||||
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
|
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
|
||||||
pn, _ := NewStub().PhraseNudge(context.Background(), c)
|
pn, _ := NewStub().PhraseNudge(context.Background(), c)
|
||||||
@@ -110,6 +112,26 @@ func TestPhraseNudgeServiceDownGenericKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two monitors down at once must both be named — he needs to know the blast
|
||||||
|
// radius, and "a service is down" was the whole defect being fixed here.
|
||||||
|
func TestPhraseNudgeServiceDownNamesEveryDownMonitor(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
st := loop.State{
|
||||||
|
Now: now,
|
||||||
|
Facts: map[string]store.Fact{
|
||||||
|
"service_down:nginx": {Key: "service_down:nginx", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
|
||||||
|
"service_down:db": {Key: "service_down:db", Ts: now, Source: loop.ServiceDownSource, Value: `"down"`},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c := loop.Candidate{Rule: loop.ServiceDownRule(), Severity: loop.Sev4, State: st}
|
||||||
|
pn, _ := NewStub().PhraseNudge(context.Background(), c)
|
||||||
|
for _, want := range []string{"nginx", "db"} {
|
||||||
|
if !strings.Contains(pn.Body, want) {
|
||||||
|
t.Fatalf("body should name %q, got %q", want, pn.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPhraseNudgeUnknownRuleFallsBack(t *testing.T) {
|
func TestPhraseNudgeUnknownRuleFallsBack(t *testing.T) {
|
||||||
// a rule without a dedicated template — generic fallback names the rule +
|
// a rule without a dedicated template — generic fallback names the rule +
|
||||||
// severity gist. never empty.
|
// severity gist. never empty.
|
||||||
|
|||||||
@@ -254,6 +254,41 @@ func (s *Store) LatestFactBySource(ctx context.Context, key, source string) (Fac
|
|||||||
return scanFact(row)
|
return scanFact(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LatestFactsByPrefix — the latest non-voided fact for every key that starts
|
||||||
|
// with prefix, newest-per-key, ordered by key.
|
||||||
|
//
|
||||||
|
// The loop's gatherer loads the keys its rules declare, which works while the
|
||||||
|
// key set is static. Kuma's monitors are not: one fact per monitor means the
|
||||||
|
// keys are only known once the gauge is read, so the rule declares the prefix
|
||||||
|
// and this read resolves it per tick. `_` and `%` are escaped — a monitor name
|
||||||
|
// is user text and must not act as a LIKE wildcard.
|
||||||
|
func (s *Store) LatestFactsByPrefix(ctx context.Context, prefix string) ([]Fact, error) {
|
||||||
|
esc := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(prefix)
|
||||||
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT id, ts, kind, key, value, source, confidence, voids_id
|
||||||
|
FROM facts f
|
||||||
|
WHERE key LIKE ? ESCAPE '\'
|
||||||
|
AND id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
|
||||||
|
AND id = (SELECT id FROM facts g
|
||||||
|
WHERE g.key = f.key
|
||||||
|
AND g.id NOT IN (SELECT voids_id FROM facts WHERE voids_id IS NOT NULL)
|
||||||
|
ORDER BY g.ts DESC, g.id DESC LIMIT 1)
|
||||||
|
ORDER BY key`, esc+"%")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("facts by prefix %q: %w", prefix, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []Fact
|
||||||
|
for rows.Next() {
|
||||||
|
f, err := scanFact(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// Since returns how long ago the latest non-voided fact for key landed, or
|
// Since returns how long ago the latest non-voided fact for key landed, or
|
||||||
// (0, ErrNoFact). Implements the `since(key)==null → don't fire` guard from
|
// (0, ErrNoFact). Implements the `since(key)==null → don't fire` guard from
|
||||||
// the spec — silence on no-data is "shuts up when uncertain".
|
// the spec — silence on no-data is "shuts up when uncertain".
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// One fact per kuma monitor means the loop cannot name its keys at wiring time,
|
||||||
|
// so it asks for the family by prefix. The read must return the newest row per
|
||||||
|
// key and stop at the prefix boundary.
|
||||||
|
func TestLatestFactsByPrefix(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||||
|
write := func(key, val string, at time.Time) int64 {
|
||||||
|
id, err := s.SetValue(ctx, KindEnv, key, "poll:uptimekuma", val, at)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SetValue %s: %v", key, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
write("service_down:db", "up", now.Add(-2*time.Hour))
|
||||||
|
write("service_down:db", "down", now.Add(-time.Hour)) // newer wins
|
||||||
|
write("service_down:web", "up", now.Add(-time.Hour))
|
||||||
|
write("service_downtime", "irrelevant", now) // no colon, not in the family
|
||||||
|
write("water", "250ml", now)
|
||||||
|
|
||||||
|
got, err := s.LatestFactsByPrefix(ctx, "service_down:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LatestFactsByPrefix: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d facts, want 2: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0].Key != "service_down:db" || got[0].Value != `"down"` {
|
||||||
|
t.Errorf("first = %s=%s, want the newest db row", got[0].Key, got[0].Value)
|
||||||
|
}
|
||||||
|
if got[1].Key != "service_down:web" {
|
||||||
|
t.Errorf("second = %s, want service_down:web", got[1].Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A monitor name is user text. An underscore in it must match itself, not act
|
||||||
|
// as a LIKE wildcard and drag in every other monitor.
|
||||||
|
func TestLatestFactsByPrefixEscapesWildcards(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||||
|
for _, k := range []string{"a_b:one", "axb:two"} {
|
||||||
|
if _, err := s.SetValue(ctx, KindEnv, k, "poll:uptimekuma", "down", now); err != nil {
|
||||||
|
t.Fatalf("SetValue %s: %v", k, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, err := s.LatestFactsByPrefix(ctx, "a_b:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LatestFactsByPrefix: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].Key != "a_b:one" {
|
||||||
|
t.Fatalf("got %+v, want only a_b:one", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -219,6 +219,35 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
|||||||
// event, and the old rows would otherwise be recited as extra meetings.
|
// event, and the old rows would otherwise be recited as extra meetings.
|
||||||
// The filter is exact — it keeps any key whose summary part still has a
|
// The filter is exact — it keeps any key whose summary part still has a
|
||||||
// letter or a digit in it.
|
// letter or a digit in it.
|
||||||
|
|
||||||
|
// #19 — unstick the routines accepted before the fire-forever fix
|
||||||
|
// (Vikunja #377, follow-up to #366). Accepting used to leave accepted_ts
|
||||||
|
// NULL and a live one-shot reminder behind, and the tick loop skips a row
|
||||||
|
// with no accepted_ts, so every non-weekly routine accepted before that fix
|
||||||
|
// has been silent ever since.
|
||||||
|
//
|
||||||
|
// Three statements, in this order, per stuck row: adopt created_ts as the
|
||||||
|
// acceptance time, cancel the reminder that is still holding the schedule,
|
||||||
|
// then let go of it. Cancelling before clearing matters — clearing first
|
||||||
|
// loses the only pointer to the reminder and leaves it to fire on its own.
|
||||||
|
//
|
||||||
|
// created_ts rather than a fresh timestamp because a migration has no
|
||||||
|
// clock, and because the first interval should be measured from when he
|
||||||
|
// said yes. A routine whose interval has already elapsed nudges on the next
|
||||||
|
// tick, which is what being unstuck looks like.
|
||||||
|
//
|
||||||
|
// Weekly rows are included deliberately. Theirs was the case that kept
|
||||||
|
// working, because the cron reminder reschedules itself — so leaving them
|
||||||
|
// alone would give them both a cron reminder and a tick-loop schedule for
|
||||||
|
// one habit, and he would hear it twice.
|
||||||
|
`UPDATE reminders
|
||||||
|
SET status = 'cancelled'
|
||||||
|
WHERE status = 'pending'
|
||||||
|
AND id IN (SELECT reminder_id FROM proposed_routines
|
||||||
|
WHERE status = 'accepted' AND accepted_ts IS NULL AND reminder_id IS NOT NULL);
|
||||||
|
UPDATE proposed_routines
|
||||||
|
SET accepted_ts = created_ts, reminder_id = NULL
|
||||||
|
WHERE status = 'accepted' AND accepted_ts IS NULL;`,
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrate applies every migration with a number greater than the DB's current
|
// migrate applies every migration with a number greater than the DB's current
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func userVersion(t *testing.T, s *Store) int {
|
func userVersion(t *testing.T, s *Store) int {
|
||||||
@@ -80,3 +81,69 @@ func TestCollapsedCalendarKeysAreDropped(t *testing.T) {
|
|||||||
t.Fatalf("%d calendar rows left, want the 2 that identify their event", got)
|
t.Fatalf("%d calendar rows left, want the 2 that identify their event", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStuckRoutinesAreBackfilled — routines accepted before the fire-forever
|
||||||
|
// fix have accepted_ts NULL and a live reminder, so the tick loop skips them
|
||||||
|
// and they have been silent ever since (Vikunja #377). The migration touches
|
||||||
|
// live reminders, which is why it is tested against a real store.
|
||||||
|
func TestStuckRoutinesAreBackfilled(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestStore(t)
|
||||||
|
created := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
rem, err := s.CreateReminder(ctx, created.Add(time.Hour), "полить цветы", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
healthy, err := s.CreateReminder(ctx, created.Add(2*time.Hour), "не трогать", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts, reminder_id, accepted_ts)
|
||||||
|
VALUES ('water', 'plants', 7, 'accepted', ?, ?, NULL)`,
|
||||||
|
created.UnixMilli(), rem); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// An already-healthy accepted row, and a still-open proposal: neither is
|
||||||
|
// this migration's business.
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts, accepted_ts)
|
||||||
|
VALUES ('feed', 'cat', 1, 'accepted', ?, ?)`,
|
||||||
|
created.UnixMilli(), created.UnixMilli()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.db.ExecContext(ctx, migrations[18]); err != nil {
|
||||||
|
t.Fatalf("migration 19: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted, err := s.ListAcceptedRoutines(ctx)
|
||||||
|
if err != nil || len(accepted) != 2 {
|
||||||
|
t.Fatalf("ListAcceptedRoutines = %d rows, err=%v, want 2", len(accepted), err)
|
||||||
|
}
|
||||||
|
stuck := accepted[0]
|
||||||
|
if stuck.Object != "plants" {
|
||||||
|
stuck = accepted[1]
|
||||||
|
}
|
||||||
|
if stuck.AcceptedTs == nil || !stuck.AcceptedTs.Equal(created) {
|
||||||
|
t.Fatalf("accepted_ts = %v, want the creation time", stuck.AcceptedTs)
|
||||||
|
}
|
||||||
|
if stuck.ReminderID != nil {
|
||||||
|
t.Fatalf("reminder_id = %v, want it let go", stuck.ReminderID)
|
||||||
|
}
|
||||||
|
// The reminder it was holding is cancelled, and nothing else is.
|
||||||
|
var status string
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT status FROM reminders WHERE id = ?`, rem).Scan(&status); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != ReminderCancelled {
|
||||||
|
t.Fatalf("linked reminder status = %q, want cancelled", status)
|
||||||
|
}
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT status FROM reminders WHERE id = ?`, healthy).Scan(&status); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if status != "pending" {
|
||||||
|
t.Fatalf("unrelated reminder status = %q, want it untouched", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user