Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0db31d21b9 | |||
| df3220d039 | |||
| 76a251a20d | |||
| 724e90759e | |||
| 2bf11f052d | |||
| 2ca5ffa4f9 | |||
| 77888c1a9c | |||
| 71b42e31bd | |||
| cb04799b09 | |||
| 2a3701d012 | |||
| 327726a06a | |||
| 57161fb762 | |||
| 8846b7e43c | |||
| b436be69c3 | |||
| d0e98a9419 | |||
| 9a9f4464d5 | |||
| 543aefde4b | |||
| e926e4e6df | |||
| 694d9e4e45 | |||
| 4b052fb9d2 | |||
| 61ba58388f | |||
| 5e52b55ee9 | |||
| 59cdcc4e19 | |||
| 3588da9e28 | |||
| d3fcc1dfdb | |||
| 89afe4ca99 | |||
| fa783cba8f | |||
| f8af9299dd | |||
| 5c17b2db06 | |||
| 6316354518 | |||
| 88d25d31ac | |||
| 708a69375f | |||
| d68708b5e1 | |||
| 3ff2a9340a | |||
| 617476772e | |||
| 802d5961ac | |||
| 252f773223 | |||
| f432eb0b25 | |||
| 5aaecd2a53 | |||
| ec5167de3a | |||
| f02f3b55b6 | |||
| da62a2f25e | |||
| 52f56947bb | |||
| 5e0417306b | |||
| 87d03cf8c6 | |||
| 1c94df76b7 | |||
| 9e383eb751 | |||
| 8c6332f95c | |||
| bddf52d1ee | |||
| b3c2fad4ec | |||
| d62ba093f5 | |||
| c21d8fdcee | |||
| 810076451f | |||
| fa799bc051 | |||
| 4757ff6d7b | |||
| 7ab9b48259 | |||
| aee20a6abc | |||
| b2eb08bb51 | |||
| ba33a677f8 | |||
| f891a81ab2 | |||
| 38b09ded95 | |||
| 6c81df17ec | |||
| d69a1f8076 | |||
| e4bfcd958f | |||
| 012bdcc1ae | |||
| 4f012e350c | |||
| 4e4c9170e3 | |||
| 88c841cb0e | |||
| 0e83ddf3df | |||
| 49dfeb879e | |||
| 7f42cc73be | |||
| 927e46bca3 | |||
| 08f3db318f | |||
| 69e2800ef3 | |||
| a8fcb404be | |||
| dc4c5b7841 | |||
| 33e53ee897 | |||
| 45b5e16eff | |||
| 4eca20bd94 | |||
| fed33a4e16 | |||
| 62cc072f8c | |||
| 7c7bd8ceeb | |||
| aa1a26532c | |||
| d92349ca6e | |||
| 8d5e357b57 | |||
| 95ae900a58 |
@@ -52,3 +52,5 @@ coverage.out
|
||||
|
||||
# Agent worktrees and local agent state
|
||||
.claude/
|
||||
/models/stt
|
||||
/models/tts
|
||||
|
||||
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
|
||||
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
||||
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
||||
|
||||
.PHONY: all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models
|
||||
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models
|
||||
|
||||
all: build
|
||||
|
||||
@@ -91,6 +91,14 @@ vet:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
$(GO) vet ./internal/... ./cmd/...
|
||||
|
||||
# simulate — replay every scripted day under cmd/mavend/testdata/scenarios
|
||||
# through the real router, store, tick loop and intake journal, on a fake clock
|
||||
# (Vikunja #284). Verbose so the transcript of each scenario lands in the
|
||||
# terminal. Also runs as part of `make test`; this target is for reading it.
|
||||
simulate:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
$(GO) test -v -count=1 -run TestSimulator ./cmd/mavend/
|
||||
|
||||
test: fmt-check vet
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
$(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/...
|
||||
@@ -139,6 +147,22 @@ eval-models:
|
||||
MAVEN_LLM_URL="$(MAVEN_LLM_URL)" $(GO) test -v -count=1 -timeout 60m \
|
||||
-run TestLLMRouterBaseline ./internal/router/eval/
|
||||
|
||||
# stt-fixtures — regenerate the golden STT audio in cmd/mavsttd/testdata from
|
||||
# the piper voices (#288). The committed WAVs are synthesised, never recorded,
|
||||
# so this is the only way they should ever change. The spoken text is read out
|
||||
# of testdata/golden_v1.json, so edit the transcript there and rerun this.
|
||||
#
|
||||
# test-stt-golden runs both golden tests: TestGoldenAudioTranscription, which
|
||||
# scores the fixtures against ggml-small and self-skips when the model is
|
||||
# absent, and TestGoldenFixturesAreCanonical, which checks the committed audio
|
||||
# and the manifest with no model at all.
|
||||
stt-fixtures:
|
||||
./scripts/gen-stt-fixtures.sh
|
||||
|
||||
test-stt-golden:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
$(GO) test -v -count=1 -run TestGolden ./cmd/mavsttd/
|
||||
|
||||
run-stt: build-stt
|
||||
LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
./mavsttd -socket /tmp/maven/stt.sock -model $(WHISPER_MODEL)
|
||||
|
||||
+25
-13
@@ -66,7 +66,7 @@ func run(args []string) error {
|
||||
if *url == "" || *user == "" || *pass == "" {
|
||||
return fmt.Errorf("-url, -user, -pass are required")
|
||||
}
|
||||
if err := checkRenderTarget(*url, *renderURL); err != nil {
|
||||
if err := checkRenderTarget([]string{*url}, *renderURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -122,17 +122,26 @@ func run(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// checkRenderTarget refuses a render URL that is also a read URL. This is the
|
||||
// structural half of #127's "cannot write to your work calendar": the write
|
||||
// credential and the write URL are separate flags, and the one calendar maven
|
||||
// is known to only read is rejected as a target at startup rather than trusted
|
||||
// at runtime.
|
||||
func checkRenderTarget(readURL, renderURL string) error {
|
||||
// checkRenderTarget refuses a render URL that is also one of the read URLs.
|
||||
// This is the structural half of #127's "cannot write to your work calendar":
|
||||
// the write credential and the write URL are separate flags, and a calendar
|
||||
// maven is known to only read is rejected as a target at startup rather than
|
||||
// trusted at runtime.
|
||||
//
|
||||
// It takes the whole read set, not one URL. The guarantee in the package
|
||||
// comment is about every calendar maven reads, and a second read target added
|
||||
// later must not quietly fall outside the check.
|
||||
func checkRenderTarget(readURLs []string, renderURL string) error {
|
||||
if renderURL == "" {
|
||||
return nil
|
||||
}
|
||||
if sameCollection(readURL, renderURL) {
|
||||
return fmt.Errorf("-render-url must differ from -url: maven renders into a calendar she owns, never into one she reads")
|
||||
for _, read := range readURLs {
|
||||
if read == "" {
|
||||
continue
|
||||
}
|
||||
if sameCollection(read, renderURL) {
|
||||
return fmt.Errorf("-render-url must differ from the read URL %s: maven renders into a calendar she owns, never into one she reads", read)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -163,7 +172,7 @@ func (p *poller) pollOnce(ctx context.Context) {
|
||||
}
|
||||
|
||||
// Write calendar_busy on change.
|
||||
if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now, 1.0); err != nil {
|
||||
if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now); err != nil {
|
||||
log.Printf("mavcaldav: write calendar_busy: %v", err)
|
||||
return
|
||||
}
|
||||
@@ -173,7 +182,7 @@ func (p *poller) pollOnce(ctx context.Context) {
|
||||
// reaching back to Radicale.
|
||||
for _, e := range events {
|
||||
key := calendar.FactKey(e)
|
||||
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start, 1.0); err != nil {
|
||||
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start); err != nil {
|
||||
log.Printf("mavcaldav: write %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
@@ -206,7 +215,10 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Eve
|
||||
}
|
||||
|
||||
// writeIfChanged writes a fact only when the value differs from the latest.
|
||||
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time, confidence float64) error {
|
||||
// Everything this poller writes is a calendar read, which is full confidence by
|
||||
// definition; a source that is not, such as the notification relay, does not
|
||||
// come through here.
|
||||
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time) error {
|
||||
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
||||
switch {
|
||||
case err == nil && prev.Value == val:
|
||||
@@ -220,7 +232,7 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts
|
||||
Key: key,
|
||||
Value: val,
|
||||
Source: source,
|
||||
Confidence: confidence,
|
||||
Confidence: 1.0,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("write %s: %w", key, err)
|
||||
|
||||
@@ -62,7 +62,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
t.Run("no previous fact writes", func(t *testing.T) {
|
||||
fc := &fakeCore{}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
},
|
||||
}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
},
|
||||
}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now, 1.0)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -121,7 +121,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
t.Run("read error other than ErrNoFact returns error", func(t *testing.T) {
|
||||
fc := &fakeCore{readErr: fmt.Errorf("connection refused")}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now, 1.0)
|
||||
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
writeErr: fmt.Errorf("disk full"),
|
||||
}
|
||||
p := &poller{core: fc}
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -190,13 +190,15 @@ func TestPollOnce(t *testing.T) {
|
||||
t.Errorf("calendar_busy ts is zero")
|
||||
}
|
||||
|
||||
// Second write: calendar_event_<date>_<summary> = "<summary> @ HH:MM-HH:MM"
|
||||
// Second write: calendar_event_<date>_<summary> = "<summary> @ HH:MM-HH:MM".
|
||||
// The iCal states the event in UTC and the fact is stamped on the owner's
|
||||
// clock, so the expected key date and times are the local reading of it.
|
||||
eventReq := fc.writeLog[1]
|
||||
expectedKey := "calendar_event_" + start.Format("20060102") + "_Current-meeting"
|
||||
expectedKey := "calendar_event_" + start.Local().Format("20060102") + "_Current-meeting"
|
||||
if eventReq.Key != expectedKey {
|
||||
t.Errorf("event key = %q, want %q", eventReq.Key, expectedKey)
|
||||
}
|
||||
expectedVal := "Current meeting @ " + start.Format("15:04") + "-" + end.Format("15:04")
|
||||
expectedVal := "Current meeting @ " + start.Local().Format("15:04") + "-" + end.Local().Format("15:04")
|
||||
if eventReq.Value != expectedVal {
|
||||
t.Errorf("event value = %q, want %q", eventReq.Value, expectedVal)
|
||||
}
|
||||
|
||||
+80
-3
@@ -2,15 +2,18 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// renderer is the write half of maven's own local calendar (Vikunja #127).
|
||||
@@ -38,6 +41,13 @@ type renderer struct {
|
||||
// unchanged reminder costs nothing. Purely an optimisation: a restart
|
||||
// re-publishes every reminder once, which is idempotent.
|
||||
published map[int64]string
|
||||
|
||||
// reconciled — whether the collection has been read once since start. It
|
||||
// has to be, because published is in-memory: withdrawal used to cover only
|
||||
// the reminders THIS process published, so a reminder that fired while the
|
||||
// daemon was down kept its event in the calendar forever, and nothing ever
|
||||
// revisited it.
|
||||
reconciled bool
|
||||
}
|
||||
|
||||
func newRenderer(core ipc.CoreAPI, hc *http.Client, url, user, pass string, dur time.Duration) *renderer {
|
||||
@@ -64,7 +74,7 @@ func (r *renderer) renderOnce(ctx context.Context) {
|
||||
|
||||
live := make(map[int64]bool, len(reminders))
|
||||
for _, rem := range reminders {
|
||||
if rem.Status != "pending" {
|
||||
if rem.Status != store.ReminderPending {
|
||||
continue
|
||||
}
|
||||
live[rem.ID] = true
|
||||
@@ -81,10 +91,28 @@ func (r *renderer) renderOnce(ctx context.Context) {
|
||||
log.Printf("mavcaldav: rendered reminder %d (%s)", rem.ID, e.Summary)
|
||||
}
|
||||
|
||||
stale := make(map[int64]bool)
|
||||
for id := range r.published {
|
||||
if live[id] {
|
||||
continue
|
||||
if !live[id] {
|
||||
stale[id] = true
|
||||
}
|
||||
}
|
||||
if !r.reconciled {
|
||||
remote, err := r.listPublished(ctx)
|
||||
if err != nil {
|
||||
// Try again next tick. A collection maven cannot read is not a
|
||||
// reason to stop publishing to it.
|
||||
log.Printf("mavcaldav: reconcile: %v", err)
|
||||
} else {
|
||||
r.reconciled = true
|
||||
for _, id := range remote {
|
||||
if !live[id] {
|
||||
stale[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id := range stale {
|
||||
if err := r.delete(ctx, calendar.ReminderPath(id)); err != nil {
|
||||
log.Printf("mavcaldav: withdraw reminder %d: %v", id, err)
|
||||
continue
|
||||
@@ -94,6 +122,55 @@ func (r *renderer) renderOnce(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// listPublished PROPFINDs the collection and returns the reminder ids maven has
|
||||
// events for in it. Only resources carrying calendar.ReminderUIDPrefix are
|
||||
// reported, so a reconciliation pass can never propose deleting a file maven
|
||||
// did not create — the same bound every other path in this file has.
|
||||
func (r *renderer) listPublished(ctx context.Context) ([]int64, error) {
|
||||
const body = `<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/></D:prop></D:propfind>`
|
||||
req, err := http.NewRequestWithContext(ctx, "PROPFIND", r.url+"/", strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(r.user, r.pass)
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
req.Header.Set("Depth", "1")
|
||||
|
||||
resp, err := r.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusMultiStatus && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
|
||||
return nil, fmt.Errorf("PROPFIND %s: %s", r.url, resp.Status)
|
||||
}
|
||||
|
||||
var ms struct {
|
||||
Responses []struct {
|
||||
Href string `xml:"href"`
|
||||
} `xml:"response"`
|
||||
}
|
||||
if err := xml.Unmarshal(raw, &ms); err != nil {
|
||||
return nil, fmt.Errorf("PROPFIND %s: %w", r.url, err)
|
||||
}
|
||||
var ids []int64
|
||||
for _, resp := range ms.Responses {
|
||||
href, err := url.PathUnescape(strings.TrimSpace(resp.Href))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if id, ok := calendar.ReminderIDFromPath(href); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// renderMaxReminders bounds the read. Reminders past this count are older than
|
||||
// anything a calendar view is useful for.
|
||||
const renderMaxReminders = 200
|
||||
|
||||
+125
-10
@@ -2,9 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -27,12 +29,16 @@ func (c *reminderCore) ListReminders(context.Context, int) ([]ipc.Reminder, erro
|
||||
return c.reminders, nil
|
||||
}
|
||||
|
||||
// calSrv records what a CalDAV collection received.
|
||||
// calSrv records what a CalDAV collection received. existing seeds resources
|
||||
// that were already in the collection before this process started, which is
|
||||
// what a restart looks like from the renderer's side.
|
||||
type calSrv struct {
|
||||
mu sync.Mutex
|
||||
puts map[string]string
|
||||
dels []string
|
||||
status int
|
||||
mu sync.Mutex
|
||||
puts map[string]string
|
||||
dels []string
|
||||
existing []string
|
||||
propfind int
|
||||
status int
|
||||
*httptest.Server
|
||||
}
|
||||
|
||||
@@ -47,12 +53,43 @@ func newCalSrv() *calSrv {
|
||||
s.puts[strings.TrimPrefix(r.URL.Path, "/cal/")] = string(body)
|
||||
case http.MethodDelete:
|
||||
s.dels = append(s.dels, strings.TrimPrefix(r.URL.Path, "/cal/"))
|
||||
case "PROPFIND":
|
||||
s.propfind++
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
io.WriteString(w, s.multistatusLocked(r.URL.Path))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(s.status)
|
||||
}))
|
||||
return s
|
||||
}
|
||||
|
||||
// multistatusLocked renders the collection listing. Caller holds the lock.
|
||||
func (s *calSrv) multistatusLocked(base string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0"?><D:multistatus xmlns:D="DAV:">`)
|
||||
b.WriteString("<D:response><D:href>" + base + "</D:href></D:response>")
|
||||
names := append([]string{}, s.existing...)
|
||||
for name := range s.puts {
|
||||
names = append(names, name)
|
||||
}
|
||||
for _, name := range names {
|
||||
if slices.Contains(s.dels, name) {
|
||||
continue
|
||||
}
|
||||
b.WriteString("<D:response><D:href>/cal/" + name + "</D:href></D:response>")
|
||||
}
|
||||
b.WriteString("</D:multistatus>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *calSrv) deleted() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string{}, s.dels...)
|
||||
}
|
||||
|
||||
func (s *calSrv) putCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -168,19 +205,97 @@ func TestRenderOnceUsesNextFireForRecurring(t *testing.T) {
|
||||
|
||||
func TestCheckRenderTargetRefusesTheCalendarItReads(t *testing.T) {
|
||||
read := "http://localhost:5232/kami/personal"
|
||||
if err := checkRenderTarget(read, ""); err != nil {
|
||||
if err := checkRenderTarget([]string{read}, ""); err != nil {
|
||||
t.Fatalf("rendering off must be fine: %v", err)
|
||||
}
|
||||
if err := checkRenderTarget(read, "http://localhost:5232/kami/maven"); err != nil {
|
||||
if err := checkRenderTarget([]string{read}, "http://localhost:5232/kami/maven"); err != nil {
|
||||
t.Fatalf("a distinct collection must be accepted: %v", err)
|
||||
}
|
||||
if err := checkRenderTarget(read, read); err == nil {
|
||||
if err := checkRenderTarget([]string{read}, read); err == nil {
|
||||
t.Error("rendering into the read calendar must be refused")
|
||||
}
|
||||
if err := checkRenderTarget(read, read+"/"); err == nil {
|
||||
if err := checkRenderTarget([]string{read}, read+"/"); err == nil {
|
||||
t.Error("a trailing slash must not defeat the check")
|
||||
}
|
||||
if err := checkRenderTarget(read, strings.ToUpper(read)); err == nil {
|
||||
if err := checkRenderTarget([]string{read}, strings.ToUpper(read)); err == nil {
|
||||
t.Error("case must not defeat the check")
|
||||
}
|
||||
// Every read target is checked, not the first one. A second calendar to
|
||||
// read must not fall outside the guarantee just by being added later.
|
||||
work := "http://localhost:5232/kami/work"
|
||||
if err := checkRenderTarget([]string{read, work}, work); err == nil {
|
||||
t.Error("rendering into the second read calendar must be refused")
|
||||
}
|
||||
if err := checkRenderTarget([]string{read, work}, "http://localhost:5232/kami/maven"); err != nil {
|
||||
t.Fatalf("a collection maven owns must still be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Withdrawal has to survive a restart. published is in-memory, so a fresh
|
||||
// process knows nothing about the events an earlier one wrote: fire a reminder,
|
||||
// restart mavcaldav, and its event used to sit in the collection forever
|
||||
// because nothing ever revisited it. The first tick reads the collection and
|
||||
// reconciles what it finds against what is pending.
|
||||
func TestRenderOnceWithdrawsAfterRestart(t *testing.T) {
|
||||
srv := newCalSrv()
|
||||
defer srv.Close()
|
||||
// Left behind by a previous process: 4 is still pending, 5 has fired.
|
||||
// The third file is not maven's and must not be touched.
|
||||
srv.existing = []string{"maven-reminder-4.ics", "maven-reminder-5.ics", "dentist.ics"}
|
||||
|
||||
core := &reminderCore{reminders: []ipc.Reminder{
|
||||
{ID: 4, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "выпить воды", Status: "pending"},
|
||||
{ID: 5, FireTs: time.Date(2026, 8, 1, 8, 0, 0, 0, time.UTC), Payload: "уже прозвенело", Status: "fired"},
|
||||
}}
|
||||
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
|
||||
dels := srv.deleted()
|
||||
if len(dels) != 1 || dels[0] != "maven-reminder-5.ics" {
|
||||
t.Fatalf("deleted %v, want only the fired reminder's event", dels)
|
||||
}
|
||||
|
||||
// The collection is read once, not on every tick.
|
||||
r.renderOnce(context.Background())
|
||||
srv.mu.Lock()
|
||||
n := srv.propfind
|
||||
srv.mu.Unlock()
|
||||
if n != 1 {
|
||||
t.Errorf("PROPFIND ran %d times, want once per process", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A collection maven cannot read is not a reason to stop publishing to it, and
|
||||
// the reconciliation must be retried rather than skipped for the process.
|
||||
func TestRenderOnceRetriesReconcile(t *testing.T) {
|
||||
srv := newCalSrv()
|
||||
defer srv.Close()
|
||||
srv.existing = []string{"maven-reminder-6.ics"}
|
||||
failing := &http.Client{Transport: &propfindFailure{base: srv.Client().Transport}}
|
||||
|
||||
core := &reminderCore{}
|
||||
r := newRenderer(core, failing, srv.URL+"/cal", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
if got := srv.deleted(); len(got) != 0 {
|
||||
t.Fatalf("nothing can be withdrawn on a failed read: %v", got)
|
||||
}
|
||||
if r.reconciled {
|
||||
t.Fatal("a failed read must not count as reconciled")
|
||||
}
|
||||
|
||||
r.http = srv.Client()
|
||||
r.renderOnce(context.Background())
|
||||
if got := srv.deleted(); len(got) != 1 || got[0] != "maven-reminder-6.ics" {
|
||||
t.Fatalf("deleted %v, want the orphaned event on the retry", got)
|
||||
}
|
||||
}
|
||||
|
||||
// propfindFailure fails PROPFIND and passes everything else through.
|
||||
type propfindFailure struct{ base http.RoundTripper }
|
||||
|
||||
func (f *propfindFailure) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req.Method == "PROPFIND" {
|
||||
return nil, errors.New("collection unreachable")
|
||||
}
|
||||
return f.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
)
|
||||
@@ -52,6 +53,19 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
|
||||
case errors.Is(err, tool.ErrNotEnabled):
|
||||
return h.proposeGap(ctx, dec)
|
||||
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
|
||||
// The row is enabled and the backend is gone. Drafting a proposal
|
||||
// for it (the ErrNotEnabled path) would be answering the wrong
|
||||
// question.
|
||||
return "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."
|
||||
case errors.Is(err, mcp.ErrToolGone):
|
||||
return "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."
|
||||
case errors.Is(err, mcp.ErrNeedsArgs):
|
||||
// An MCP tool that wants named arguments a spoken verb cannot
|
||||
// supply. Guessing them would be a wrong act, so she says so
|
||||
// instead — the tool is still runnable from the authed surface,
|
||||
// where a human types them.
|
||||
return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."
|
||||
}
|
||||
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
||||
if out != "" {
|
||||
|
||||
+31
-16
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
@@ -28,12 +29,18 @@ import (
|
||||
// tracking is not connected". It never computes, estimates or rounds a total of
|
||||
// its own — an invented number about his money is the worst thing this could do.
|
||||
func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
window, ok := router.ParseMoneyQuery(t.dec.Utterance)
|
||||
q, ok := router.ParseMoneyQuery(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if q.Window == router.MoneyUnsupported {
|
||||
// Two windows are stored and no others. Answering "сколько я потратил
|
||||
// вчера?" with the month-to-date total answers a different question
|
||||
// with a real number, which is the shape of a lie he cannot spot.
|
||||
return "я храню только сегодняшние траты и за этот месяц.", true
|
||||
}
|
||||
key, phrase := zenmoney.KeySpentMonth, "в этом месяце"
|
||||
if window == router.MoneyToday {
|
||||
if q.Window == router.MoneyToday {
|
||||
key, phrase = zenmoney.KeySpentToday, "сегодня"
|
||||
}
|
||||
fact, err := h.api.LatestFactBySource(ctx, key, zenmoney.Source)
|
||||
@@ -51,28 +58,36 @@ func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string,
|
||||
log.Printf("voice: money fact: decode: %v", err)
|
||||
return "не получилось прочитать траты.", true
|
||||
}
|
||||
now := h.now()
|
||||
if q.Window == router.MoneyToday && !val.CoversDay(now) {
|
||||
// The day window rolled over and the poller had nothing to write,
|
||||
// because he has not spent anything yet today. The fact is fresh by ts
|
||||
// and covers yesterday, so no staleness check can catch it — only the
|
||||
// window stamp inside the value can.
|
||||
return "сегодня пока ничего не вижу.", true
|
||||
}
|
||||
reply := val.FormatRU(phrase)
|
||||
if q.Income {
|
||||
reply = val.FormatIncomeRU(phrase)
|
||||
}
|
||||
if reply == "" {
|
||||
return "по тратам пока нечего сказать.", true
|
||||
}
|
||||
// A stale fact is reported as stale rather than spoken as today's number.
|
||||
if h.now().Sub(fact.Ts) > zenmoney.StaleAfter {
|
||||
return "данные от " + fact.Ts.Local().Format("02.01") + ": " + reply, true
|
||||
// The age is measured from when the figure was last READ, not from when it
|
||||
// last changed: a month with no spending in it does not go stale.
|
||||
asOf := val.AsOf
|
||||
if asOf.IsZero() {
|
||||
asOf = fact.Ts
|
||||
}
|
||||
if now.Sub(asOf) > zenmoney.StaleAfter {
|
||||
return "данные от " + asOf.Local().Format("02.01") + ": " + reply, true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it.
|
||||
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it. The
|
||||
// hand-rolled loop this replaces missed any error implementing Is(error) bool.
|
||||
func isNoFactErr(err error) bool {
|
||||
for e := err; e != nil; {
|
||||
if e == ipc.ErrNoFact {
|
||||
return true
|
||||
}
|
||||
u, ok := e.(interface{ Unwrap() error })
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
e = u.Unwrap()
|
||||
}
|
||||
return false
|
||||
return errors.Is(err, ipc.ErrNoFact)
|
||||
}
|
||||
|
||||
@@ -137,3 +137,92 @@ func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) {
|
||||
t.Errorf("money source at %d, after notes at %d", moneyAt, notesAt)
|
||||
}
|
||||
}
|
||||
|
||||
// The day window rolls over at midnight and the poller writes nothing until the
|
||||
// first spend of the new day, so the last money_today fact is fresh by ts and
|
||||
// covers yesterday. No staleness check can catch that.
|
||||
func TestQueryMoneyRefusesYesterdaysDayTotal(t *testing.T) {
|
||||
yesterday, _ := zenmoney.DayWindow(moneyNow().AddDate(0, 0, -1))
|
||||
sum := zenmoney.Summary{From: yesterday, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 1749.5}}, Count: 3}
|
||||
val, ok := sum.Value(moneyNow().AddDate(0, 0, -1).Add(2 * time.Hour))
|
||||
if !ok {
|
||||
t.Fatal("want a fact value")
|
||||
}
|
||||
api := &moneyAPI{fact: ipc.Fact{
|
||||
Kind: "env", Key: zenmoney.KeySpentToday, Value: val,
|
||||
Source: zenmoney.Source, Ts: moneyNow().Add(-11 * time.Hour),
|
||||
}}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, claimed := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил сегодня?"},
|
||||
})
|
||||
if !claimed {
|
||||
t.Fatal("expected the source to claim it")
|
||||
}
|
||||
if strings.Contains(reply, "1749.5") {
|
||||
t.Errorf("reply = %q — that is yesterday's spending spoken as today's", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// Ts advances only when the number moves, so a quiet month used to be reported
|
||||
// as stale while being current. The read stamp inside the value is what the
|
||||
// staleness check means.
|
||||
func TestQueryMoneyMeasuresStalenessFromTheRead(t *testing.T) {
|
||||
from, _ := zenmoney.MonthWindow(moneyNow())
|
||||
sum := zenmoney.Summary{From: from, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 100}}, Count: 1}
|
||||
val, _ := sum.Value(moneyNow().Add(-time.Hour))
|
||||
// The fact itself last CHANGED three days ago: nothing was spent since.
|
||||
api := &moneyAPI{fact: ipc.Fact{
|
||||
Kind: "env", Key: zenmoney.KeySpentMonth, Value: val,
|
||||
Source: zenmoney.Source, Ts: moneyNow().Add(-72 * time.Hour),
|
||||
}}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, _ := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"},
|
||||
})
|
||||
if strings.Contains(reply, "данные от") {
|
||||
t.Errorf("reply = %q — the figure was read an hour ago and is current", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// Two windows are stored and no others. Answering "вчера" with the
|
||||
// month-to-date total answers a different question with a real number.
|
||||
func TestQueryMoneyRefusesWindowsItDoesNotKeep(t *testing.T) {
|
||||
api := &moneyAPI{}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил вчера?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("a money question must be claimed, not passed to recall")
|
||||
}
|
||||
if !strings.Contains(reply, "только") {
|
||||
t.Errorf("reply = %q, want her to say which windows she keeps", reply)
|
||||
}
|
||||
if api.callCnt != 0 {
|
||||
t.Error("a window she does not keep must not read a fact")
|
||||
}
|
||||
}
|
||||
|
||||
// "сколько я заработал" reads the same fact and must lead with the income.
|
||||
func TestQueryMoneyLeadsWithIncomeWhenAsked(t *testing.T) {
|
||||
from, _ := zenmoney.MonthWindow(moneyNow())
|
||||
sum := zenmoney.Summary{
|
||||
From: from,
|
||||
Spent: []zenmoney.Money{{Currency: "RUB", Amount: 100}},
|
||||
Earned: []zenmoney.Money{{Currency: "RUB", Amount: 3000}},
|
||||
Count: 2,
|
||||
}
|
||||
val, _ := sum.Value(moneyNow())
|
||||
api := &moneyAPI{fact: ipc.Fact{
|
||||
Kind: "env", Key: zenmoney.KeySpentMonth, Value: val,
|
||||
Source: zenmoney.Source, Ts: moneyNow(),
|
||||
}}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, _ := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я заработал в этом месяце?"},
|
||||
})
|
||||
if strings.Index(reply, "3000") > strings.Index(reply, "100") {
|
||||
t.Errorf("reply = %q, want the income he asked about first", reply)
|
||||
}
|
||||
}
|
||||
|
||||
+99
-31
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/rss"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/weather"
|
||||
)
|
||||
|
||||
@@ -74,16 +75,30 @@ var querySources = []querySource{
|
||||
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
||||
// "у меня новая лента в инстаграме" is untouched.
|
||||
{"feeds", (*reactiveHandler).queryFeeds},
|
||||
// Before "calendar" and before the recall sources: "что включено дома?" is
|
||||
// a question about the house, and the notes pass would otherwise answer it
|
||||
// from whatever he once said about the lights. Its matcher needs a house
|
||||
// marker plus an ask plus a device word, and it bails out on weather
|
||||
// wording, so "какая температура на улице?" still reaches the weather
|
||||
// source.
|
||||
{"home", (*reactiveHandler).queryHome},
|
||||
// Next to "home" and for the same reason: "какие устройства в сети?" is a
|
||||
// question about the LAN, and the recall pass would otherwise answer it
|
||||
// from an old note about the router. Its matcher needs a network word plus
|
||||
// an ask plus a device noun, so "интернет не работает" is untouched.
|
||||
{"network", (*reactiveHandler).queryNetwork},
|
||||
{"calendar", (*reactiveHandler).queryCalendar},
|
||||
{"weather", (*reactiveHandler).queryWeather},
|
||||
{"embed", (*reactiveHandler).queryEmbed},
|
||||
{"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},
|
||||
@@ -127,11 +142,16 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
||||
|
||||
// queryDayPlan — "какие планы на сегодня?", "что у меня по плану?", "что
|
||||
// дальше?" (Vikunja #128). Recites the day: calendar events, pending
|
||||
// reminders, and any morning checklist still outstanding.
|
||||
// reminders, and every morning checklist item today still has no evidence for,
|
||||
// including the ones whose window has closed.
|
||||
//
|
||||
// Read-only by construction — the plan is assembled and rendered core-side and
|
||||
// nothing here schedules or announces. "что дальше?" asks for the rest of the
|
||||
// day, so that phrasing trims what has already passed.
|
||||
//
|
||||
// What surface this belongs on is still open, tracked as Vikunja #431 ("Board
|
||||
// surface: Maven holds the work board, runs the intake form, never argues").
|
||||
// The spoken recital here is the current answer, not the decided one.
|
||||
func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !router.IsDayPlanQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
@@ -141,7 +161,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
log.Printf("voice: day plan: %v", err)
|
||||
return "не получилось собрать план.", true
|
||||
}
|
||||
if !isRestOfDayQuery(t.dec.Utterance) {
|
||||
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
||||
return plan.Spoken, true
|
||||
}
|
||||
// Rebuild the pure plan so the rest-of-day rendering is the same code that
|
||||
@@ -158,16 +178,19 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
return p.After(h.now()).FormatRU(), true
|
||||
}
|
||||
|
||||
// isRestOfDayQuery — "что дальше?" and its English form, the only plan phrasing
|
||||
// that means "from now on" rather than "the whole day".
|
||||
func isRestOfDayQuery(text string) bool {
|
||||
s := strings.ToLower(text)
|
||||
return strings.Contains(s, "дальше") || strings.Contains(s, "next")
|
||||
}
|
||||
|
||||
// habitFactWindow — how many recent facts the behaviour profile is counted
|
||||
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
|
||||
// over. Enough for a season of habits without scanning the whole store on every
|
||||
// question; the profile is recomputed on read, so the bound is the cost control.
|
||||
//
|
||||
// The read is kind-filtered in SQL, and that is the load-bearing part. When this
|
||||
// was a plain recent-facts read the window was a row budget over every writer,
|
||||
// and the machine writers dwarf the taps: mavpoll writes a wg_handshake row
|
||||
// whenever a peer rehandshakes, which is roughly every two minutes per peer, so
|
||||
// 2000 rows was under three days of history. A weekday habit needs
|
||||
// memory.MinHabitDays distinct Tuesdays, which such a window can never hold, so
|
||||
// she answered "по вторникам у меня пока нет ничего постоянного" forever on a
|
||||
// store with a year of taps in it. Self facts come from voice taps, and he does
|
||||
// not tap seven hundred times a day.
|
||||
const habitFactWindow = 2000
|
||||
|
||||
// queryHabits — "что я обычно делаю по вторникам?" (Vikunja #254). Counts the
|
||||
@@ -178,7 +201,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
facts, err := h.api.RecentFacts(ctx, habitFactWindow)
|
||||
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: habits: recent facts: %v", err)
|
||||
return "не получилось посмотреть записи.", true
|
||||
@@ -191,10 +214,13 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
|
||||
if q.HasWeekday {
|
||||
return profile.FormatWeekdayRU(q.Weekday), true
|
||||
}
|
||||
if q.Weekend {
|
||||
return profile.FormatWeekendRU(), true
|
||||
}
|
||||
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 (
|
||||
@@ -219,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
|
||||
}
|
||||
@@ -275,11 +299,53 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
||||
return f.FormatEntries(entries, date), true
|
||||
}
|
||||
|
||||
// queryHome answers a question about the house. Read-only by construction: it
|
||||
// calls States and nothing else, so there is no confirm turn here — the only
|
||||
// way to CHANGE something is an enabled allowlist row through tool.Executor.
|
||||
func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isHomeQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
if h.home == nil {
|
||||
// Fall through rather than claim the turn. A capability that is off
|
||||
// must not change what an unconfigured box answers: "какая температура
|
||||
// в доме?" on a Maven with no smarthome block reached recall before
|
||||
// this source existed, and a stored fact is a better answer than
|
||||
// "дом не подключён" from a house that was never configured. The
|
||||
// unreachable case is different and homeSummary covers it.
|
||||
return "", false
|
||||
}
|
||||
ctxH, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
return h.home.homeSummary(ctxH)
|
||||
}
|
||||
|
||||
// queryNetwork answers a question about the LAN with a bounded scan. There is
|
||||
// no confirm turn because nothing is changed, and no way to widen the range
|
||||
// because Scan takes no target — the utterance selects the question, never the
|
||||
// subnet.
|
||||
func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isNetworkQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
if h.netscan == nil {
|
||||
// Fall through, same as queryHome: an unconfigured scanner must not
|
||||
// swallow "сколько устройств в сети?" before recall has looked.
|
||||
return "", false
|
||||
}
|
||||
return h.netscan.scanSummary(ctx)
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isWeatherQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation)
|
||||
if loc == "" {
|
||||
// He named no city and voice.weather.default_location is unset. Saying
|
||||
// so is the only honest answer; picking a city would be inventing one.
|
||||
return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true
|
||||
}
|
||||
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||||
@@ -396,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()
|
||||
|
||||
@@ -42,6 +42,12 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De
|
||||
log.Printf("voice: capture task: %v", err)
|
||||
return "не получилось записать задачу.", true
|
||||
}
|
||||
if resp.Promoted {
|
||||
// It was a candidate Maven derived from something she read, and he has
|
||||
// now said it himself. Saying "уже в списке" here would be answering a
|
||||
// confirmation with a shrug.
|
||||
return "поняла, беру в работу: " + cap.Text, true
|
||||
}
|
||||
if !resp.Created {
|
||||
return "это уже в списке.", true
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type taskAPI struct {
|
||||
|
||||
captured []ipc.CaptureTaskReq
|
||||
created bool
|
||||
promoted bool
|
||||
capErr error
|
||||
|
||||
tasks []ipc.Task
|
||||
@@ -31,7 +32,7 @@ func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.Ca
|
||||
if a.capErr != nil {
|
||||
return ipc.CaptureTaskResp{}, a.capErr
|
||||
}
|
||||
return ipc.CaptureTaskResp{ID: 1, Created: a.created}, nil
|
||||
return ipc.CaptureTaskResp{ID: 1, Created: a.created, Promoted: a.promoted}, nil
|
||||
}
|
||||
|
||||
func (a *taskAPI) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||
@@ -225,3 +226,29 @@ func TestQuerySourcesOrderTasksBeforeRecall(t *testing.T) {
|
||||
t.Errorf("tasks source at %d, after notes at %d", tasksAt, notesAt)
|
||||
}
|
||||
}
|
||||
|
||||
// Saying a task out loud that Maven had only proposed is a confirmation. She
|
||||
// used to answer "это уже в списке" and then read it back, in the same
|
||||
// conversation, as something he had not confirmed.
|
||||
func TestCaptureTaskFromNoteAcknowledgesAPromotion(t *testing.T) {
|
||||
api := &taskAPI{promoted: true}
|
||||
h := taskHandler(api)
|
||||
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
||||
Intent: router.IntentNote, Utterance: "добавь в задачи продлить страховку",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("an explicit capture must claim the turn")
|
||||
}
|
||||
if strings.Contains(reply, "уже в списке") {
|
||||
t.Errorf("reply = %q — he just confirmed it, that is not a duplicate", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "продлить страховку") {
|
||||
t.Errorf("reply = %q, want the task named back", reply)
|
||||
}
|
||||
// Persona: feminine, informal.
|
||||
for _, bad := range []string{"рад ", "вы ", "ваш"} {
|
||||
if strings.Contains(reply, bad) {
|
||||
t.Errorf("reply %q contains %q", reply, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
// mavend/capture.go — core's half of the meeting recorder (Vikunja #253,
|
||||
// docs/plans/08-hearing.md).
|
||||
//
|
||||
// The split: a client that has a microphone (mavenclient, or a phone on the PWA)
|
||||
// is told to start, streams frames over ipc.MethodCaptureAppend, and is told to
|
||||
// stop. Core keeps the PCM, stores it as a WAV blob under the same media store
|
||||
// and the same retention as images, transcribes it through the ONE STT Maven has
|
||||
// (mavsttd's whisper.cpp, reused — not a second engine), and summarises the
|
||||
// transcript on the resident model in windows that fit n_ctx 4096.
|
||||
//
|
||||
// # Off unless configured, twice over
|
||||
//
|
||||
// No `media` block ⇒ nowhere to keep audio ⇒ the four capture methods do not
|
||||
// exist. No `capture` block with enabled ⇒ they still do not exist. On an
|
||||
// unconfigured box there is no wire path that starts a recording, which is the
|
||||
// only guarantee worth making about a capability like this one.
|
||||
//
|
||||
// # What this file refuses to do
|
||||
//
|
||||
// - Nothing listens. There is no VAD hook here, no wake-word branch, no
|
||||
// "start when you hear a meeting". The plan document's keyword-triggered
|
||||
// recorder is refused in internal/capture's package comment for the reason
|
||||
// that applies here too: noticing a keyword requires listening, which is
|
||||
// the behaviour this capability must not have.
|
||||
// - No transcript note by default. The summary is written where he will read
|
||||
// it; the verbatim record of what other people said takes a deliberate
|
||||
// capture.save_transcript.
|
||||
// - The transcript is never search input beyond this box, and the audio never
|
||||
// leaves it at all.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/capture"
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// captureSummaryTimeout — the budget for one summary, which is a map-reduce over
|
||||
// the whole meeting: one model call per transcript window plus a reduce, each of
|
||||
// which is seconds on this box. Forty windows is the configured ceiling, so the
|
||||
// budget has to be minutes, not the 60s the reply path uses. It is spent on a
|
||||
// background goroutine, never inside the capture_stop request: a client that
|
||||
// asks Maven to stop recording gets the transcript back in seconds.
|
||||
const captureSummaryTimeout = 20 * time.Minute
|
||||
|
||||
// llmCompleter adapts *llm.Client to capture.Completer. The pure package names
|
||||
// the two strings it needs and stays free of the llm request struct; the client
|
||||
// itself is the swap-aware one from llmClientFor, so a model swap re-points it.
|
||||
type llmCompleter struct {
|
||||
c *llm.Client
|
||||
maxTokens int
|
||||
}
|
||||
|
||||
func (l llmCompleter) Complete(ctx context.Context, system, user string) (string, error) {
|
||||
return l.c.Complete(ctx, llm.Req{System: system, User: user, MaxTokens: l.maxTokens})
|
||||
}
|
||||
|
||||
// captureWiring — the recorder plus what it needs to write the result down.
|
||||
type captureWiring struct {
|
||||
rec *capture.Recorder
|
||||
st *store.Store
|
||||
emb router.Embedder
|
||||
cfg *config.CaptureConfig
|
||||
now func() time.Time
|
||||
|
||||
// ctx and wg belong to the daemon, not to the request. Summarising happens
|
||||
// after the reply has gone out, so it needs a lifetime that outlives the
|
||||
// call and a shutdown that waits for it.
|
||||
ctx context.Context
|
||||
wg *sync.WaitGroup
|
||||
}
|
||||
|
||||
// newCaptureWiring returns nil when the recorder should not exist: no media
|
||||
// store, no capture block, capture disabled, or no STT to transcribe with.
|
||||
//
|
||||
// A missing llama-server is NOT a reason to return nil. Without one the
|
||||
// recording is still made, stored and transcribed, and the summary is simply
|
||||
// absent — the honest degradation, and much better than refusing to record a
|
||||
// meeting that is happening now.
|
||||
func newCaptureWiring(ctx context.Context, wg *sync.WaitGroup, keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, emb router.Embedder, cfg *config.Config) *captureWiring {
|
||||
if keeper == nil || !cfg.Capture.Records() {
|
||||
return nil
|
||||
}
|
||||
tr := transcriberOf(voiceW)
|
||||
if tr == nil {
|
||||
// Voice off ⇒ no STT client ⇒ nothing could turn the audio into words.
|
||||
// Storing hours of unreadable audio of other people is worse than not
|
||||
// recording, so this is a refusal, not a degradation.
|
||||
log.Printf("capture: enabled but voice/stt is not wired — meeting capture disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
cc := cfg.Capture
|
||||
var sum *capture.Summarizer
|
||||
if lp, ok := phr.(*phraser.LLMPhraser); ok {
|
||||
client := llmClientFor(lp, captureSummaryTimeout)
|
||||
sum = capture.NewSummarizer(
|
||||
llmCompleter{c: client, maxTokens: 512},
|
||||
cc.ChunkRunes, cc.MaxChunks, contextBlockFn(cfg, time.Now),
|
||||
)
|
||||
} else {
|
||||
log.Printf("capture: no llama-server phraser — meetings are transcribed, not summarised")
|
||||
}
|
||||
|
||||
rec, err := capture.New(keeper.store, tr, sum, capture.Config{
|
||||
MaxDuration: cc.MaxDuration(),
|
||||
STTWindow: time.Duration(cc.STTWindow),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("capture: %v — meeting capture disabled", err)
|
||||
return nil
|
||||
}
|
||||
log.Printf("capture: enabled, sessions capped at %s", rec.MaxDuration())
|
||||
return &captureWiring{rec: rec, st: st, emb: emb, cfg: cc, now: time.Now, ctx: ctx, wg: wg}
|
||||
}
|
||||
|
||||
// start handles ipc.MethodCaptureStart.
|
||||
func (c *captureWiring) start(_ context.Context, req ipc.CaptureStartReq) (ipc.CaptureStartResp, error) {
|
||||
s, err := c.rec.Start(req.Label)
|
||||
if err != nil {
|
||||
return ipc.CaptureStartResp{}, err
|
||||
}
|
||||
// The label is logged; nothing that was said ever is.
|
||||
log.Printf("capture: started %q", s.Label)
|
||||
return ipc.CaptureStartResp{
|
||||
Label: s.Label,
|
||||
Started: s.Started,
|
||||
Token: s.Token,
|
||||
MaxSeconds: int(c.rec.MaxDuration().Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// append handles ipc.MethodCaptureAppend. ErrExpired is reported as a successful
|
||||
// response with Expired set rather than an error: the cap firing is the designed
|
||||
// behaviour, and the client needs the flag to stop sending and call stop.
|
||||
func (c *captureWiring) append(_ context.Context, req ipc.CaptureAppendReq) (ipc.CaptureAppendResp, error) {
|
||||
err := c.rec.Append(req.Token, req.Audio)
|
||||
st := c.rec.Status()
|
||||
if errors.Is(err, capture.ErrExpired) {
|
||||
log.Printf("capture: %q hit the %s cap — stopping", st.Label, c.rec.MaxDuration())
|
||||
return ipc.CaptureAppendResp{Seconds: st.Duration.Seconds(), Expired: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ipc.CaptureAppendResp{}, err
|
||||
}
|
||||
return ipc.CaptureAppendResp{Seconds: st.Duration.Seconds()}, nil
|
||||
}
|
||||
|
||||
// stop handles ipc.MethodCaptureStop.
|
||||
//
|
||||
// The error handling here mirrors vision's, and for the same reason: the audio is
|
||||
// stored first, so a transcription failure returns what exists rather than
|
||||
// nothing. A response can carry a blob id with no transcript (STT failed,
|
||||
// re-runnable) — a degraded success, not an error to the caller.
|
||||
//
|
||||
// Summarising is NOT done here. A two-hour meeting is forty model calls, which
|
||||
// on this box is minutes, and holding the IPC request open for them means the
|
||||
// client that said "стоп" sits there with no answer while its own deadline runs
|
||||
// out. Stop returns the transcript, and the summary note is written by a
|
||||
// goroutine in the daemon's WaitGroup afterwards.
|
||||
func (c *captureWiring) stop(ctx context.Context, req ipc.CaptureStopReq) (ipc.CaptureStopResp, error) {
|
||||
if req.Discard {
|
||||
// "забудь, не записывай" — nothing is stored, transcribed or noted.
|
||||
if !c.rec.Abort(req.Token) {
|
||||
return ipc.CaptureStopResp{}, capture.ErrNoSession
|
||||
}
|
||||
log.Printf("capture: session discarded on request")
|
||||
return ipc.CaptureStopResp{Discarded: true}, nil
|
||||
}
|
||||
|
||||
res, err := c.rec.Stop(ctx, req.Token)
|
||||
resp := ipc.CaptureStopResp{
|
||||
BlobID: res.BlobID,
|
||||
Label: res.Label,
|
||||
Started: res.Started,
|
||||
Seconds: res.Duration.Seconds(),
|
||||
Transcript: res.Transcript,
|
||||
Summary: res.Summary,
|
||||
Chunks: res.Chunks,
|
||||
}
|
||||
if err != nil {
|
||||
if res.BlobID == "" && res.Transcript == "" {
|
||||
// Nothing survived: no session, or an empty recording. There is
|
||||
// nothing to hand back, so this is a real error.
|
||||
return ipc.CaptureStopResp{}, err
|
||||
}
|
||||
log.Printf("capture: %q partially finished: %v", res.Label, err)
|
||||
}
|
||||
|
||||
c.summarizeLater(res)
|
||||
log.Printf("capture: finished %q — %s of audio, %d bytes of transcript",
|
||||
res.Label, res.Duration.Round(time.Second), len(res.Transcript))
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// summarizeLater runs the map-reduce and writes the notes after stop replied.
|
||||
// The context is the daemon's, not the request's: the request is already
|
||||
// answered, and cancelling the summary because the client hung up would throw
|
||||
// away the only readable record of the meeting.
|
||||
func (c *captureWiring) summarizeLater(res capture.Result) {
|
||||
if res.Transcript == "" {
|
||||
return
|
||||
}
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
ctx, cancel := context.WithTimeout(c.ctx, captureSummaryTimeout)
|
||||
defer cancel()
|
||||
if err := c.rec.Summarize(ctx, &res); err != nil {
|
||||
// Not fatal: writeNotes falls back to the transcript, so a dead
|
||||
// llama-server costs the summary and not the meeting.
|
||||
log.Printf("capture: summary for %q failed: %v", res.Label, err)
|
||||
}
|
||||
if _, err := c.writeNotes(ctx, res); err != nil {
|
||||
log.Printf("capture: note write for %q failed: %v", res.Label, err)
|
||||
return
|
||||
}
|
||||
log.Printf("capture: summarised %q in %d chunk(s)", res.Label, res.Chunks)
|
||||
}()
|
||||
}
|
||||
|
||||
// writeNotes stores the summary as a note, and the transcript too when
|
||||
// capture.save_transcript is set. Returns the id of the note that carries the
|
||||
// meeting.
|
||||
//
|
||||
// With no summary the transcript is written instead, whatever save_transcript
|
||||
// says. That flag is about keeping the verbatim record IN ADDITION to a summary,
|
||||
// not about whether the meeting is remembered at all. Without this fallback a
|
||||
// llama-server that was down at stop time meant an hour of recorded meeting left
|
||||
// no note behind and nothing recalled it later.
|
||||
//
|
||||
// The note source carries the blob id, which is the only link back to the audio.
|
||||
// When retention prunes the blob the note remains — words about a meeting are a
|
||||
// far lighter thing to keep than a recording of it.
|
||||
func (c *captureWiring) writeNotes(ctx context.Context, res capture.Result) (int64, error) {
|
||||
source := "capture:meeting"
|
||||
if res.BlobID != "" {
|
||||
source = "capture:meeting:" + res.BlobID[:12]
|
||||
}
|
||||
var id int64
|
||||
if text := res.Summary; text != "" {
|
||||
var err error
|
||||
id, err = c.writeNote(ctx, text, source)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("summary note: %w", err)
|
||||
}
|
||||
} else if res.Transcript != "" {
|
||||
var err error
|
||||
id, err = c.writeNote(ctx, res.Transcript, source+":transcript")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("transcript note: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
if c.cfg.SaveTranscript && res.Transcript != "" {
|
||||
if _, err := c.writeNote(ctx, res.Transcript, source+":transcript"); err != nil {
|
||||
return id, fmt.Errorf("transcript note: %w", err)
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (c *captureWiring) writeNote(ctx context.Context, text, source string) (int64, error) {
|
||||
var vec []float32
|
||||
if c.emb != nil {
|
||||
// EmbedPassage, not Embed: this is text being searched FOR, and the e5
|
||||
// embedder is asymmetric. Backwards here makes the meeting unfindable by
|
||||
// the question that should have matched it.
|
||||
var err error
|
||||
vec, err = router.EmbedPassage(ctx, c.emb, text)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("embed: %w", err)
|
||||
}
|
||||
}
|
||||
return c.st.WriteNote(ctx, c.now(), text, vec, source)
|
||||
}
|
||||
|
||||
// status handles ipc.MethodCaptureStatus.
|
||||
func (c *captureWiring) status(_ context.Context) (ipc.CaptureStatusResp, error) {
|
||||
st := c.rec.Status()
|
||||
return ipc.CaptureStatusResp{
|
||||
Running: st.Running,
|
||||
Label: st.Label,
|
||||
Started: st.Started,
|
||||
Seconds: st.Duration.Seconds(),
|
||||
Bytes: st.Bytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// wireCapture installs the four IPC hooks, or leaves them nil so every capture
|
||||
// method reports ErrUnknownMethod. Takes the media keeper wireVision already
|
||||
// opened: one blob store, one retention loop, images and audio side by side.
|
||||
func wireCapture(ctx context.Context, wg *sync.WaitGroup, srv *ipc.Server, keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, cfg *config.Config) {
|
||||
cw := newCaptureWiring(ctx, wg, keeper, st, voiceW, phr, embedderOf(voiceW), cfg)
|
||||
if cw == nil {
|
||||
return
|
||||
}
|
||||
srv.CaptureStartFn = cw.start
|
||||
srv.CaptureAppendFn = cw.append
|
||||
srv.CaptureStopFn = cw.stop
|
||||
srv.CaptureStatusFn = cw.status
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/capture"
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/media"
|
||||
)
|
||||
|
||||
// silentTranscriber stands in for mavsttd: one fixed phrase per window, so the
|
||||
// wiring can be tested without whisper.
|
||||
type silentTranscriber struct{}
|
||||
|
||||
func (silentTranscriber) Transcribe(_ context.Context, _ audio.Audio) (string, float64, error) {
|
||||
return "решили купить насос", 1.0, nil
|
||||
}
|
||||
|
||||
func testCaptureWiring(t *testing.T) (*captureWiring, *sync.WaitGroup) {
|
||||
t.Helper()
|
||||
blobs, err := media.Open(t.TempDir(), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, err := capture.New(blobs, silentTranscriber{}, nil, capture.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
return &captureWiring{
|
||||
rec: rec,
|
||||
st: newTestStore(t),
|
||||
cfg: &config.CaptureConfig{},
|
||||
now: time.Now,
|
||||
ctx: context.Background(),
|
||||
wg: &wg,
|
||||
}, &wg
|
||||
}
|
||||
|
||||
// A frame carrying the wrong token must not land in the running session. Append
|
||||
// and stop used to address "whatever is running now", so a client whose session
|
||||
// had already ended went on recording into somebody else's meeting, and any
|
||||
// client could end a recording it never started.
|
||||
func TestCaptureRefusesAnotherClientsToken(t *testing.T) {
|
||||
c, _ := testCaptureWiring(t)
|
||||
start, err := c.start(context.Background(), ipc.CaptureStartReq{Label: "встреча"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if start.Token == "" {
|
||||
t.Fatal("start handed back no session token")
|
||||
}
|
||||
if _, err := c.append(context.Background(), ipc.CaptureAppendReq{
|
||||
Token: "not-mine",
|
||||
Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 3200)},
|
||||
}); err == nil {
|
||||
t.Error("a frame with the wrong token was accepted")
|
||||
}
|
||||
if _, err := c.stop(context.Background(), ipc.CaptureStopReq{Token: "not-mine"}); err == nil {
|
||||
t.Error("a stop with the wrong token ended the session")
|
||||
}
|
||||
if st, _ := c.status(context.Background()); !st.Running {
|
||||
t.Error("the session was ended by a client that does not own it")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop answers with the transcript and does not wait for the summary. The
|
||||
// summary is up to forty model calls, and holding the IPC request for them meant
|
||||
// the client that said "стоп" sat with no answer for minutes.
|
||||
//
|
||||
// With no summariser wired the note still has to be written, from the transcript.
|
||||
// save_transcript is about keeping the verbatim record IN ADDITION to a summary,
|
||||
// not about whether the meeting is remembered at all — without this fallback a
|
||||
// dead llama-server meant an hour of meeting left no note behind.
|
||||
func TestStopReturnsTranscriptAndNotesItWithoutASummary(t *testing.T) {
|
||||
c, wg := testCaptureWiring(t)
|
||||
start, err := c.start(context.Background(), ipc.CaptureStartReq{Label: "планёрка"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.append(context.Background(), ipc.CaptureAppendReq{
|
||||
Token: start.Token,
|
||||
Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 32000)},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := c.stop(context.Background(), ipc.CaptureStopReq{Token: start.Token})
|
||||
if err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if resp.Transcript == "" {
|
||||
t.Fatal("stop returned no transcript")
|
||||
}
|
||||
if resp.Summary != "" {
|
||||
t.Errorf("summary = %q, want none inside the request", resp.Summary)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
notes, err := c.st.RecentNotes(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found bool
|
||||
for _, n := range notes {
|
||||
if strings.Contains(n.Text, "насос") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("the meeting left no note behind: %+v", notes)
|
||||
}
|
||||
}
|
||||
+70
-4
@@ -17,7 +17,8 @@ import (
|
||||
const clarifyTTL = 90 * time.Second
|
||||
|
||||
// wantedSlots — what each intent needs before she can act on it. First entry is
|
||||
// the one she asks about; the rest are only used to decide act-vs-drop.
|
||||
// the one she asks about this turn; the rest are asked about on later turns, one
|
||||
// per turn, as each answer lands (see askRemainingGap).
|
||||
//
|
||||
// Intents not listed here are never worth a question: note and query act on the
|
||||
// raw utterance, chat and system have nothing to fill in. For those a clarify
|
||||
@@ -25,8 +26,7 @@ const clarifyTTL = 90 * time.Second
|
||||
// is worse than admitting she missed it.
|
||||
// A reminder wants BOTH what to remind about and when. Subject first: "напомни
|
||||
// в 11" has a time and nothing to say at 11, and a reminder with no subject is
|
||||
// not worth setting. Order here is the order she asks in — she still only asks
|
||||
// about the first one missing.
|
||||
// not worth setting. Order here is the order she asks in.
|
||||
var wantedSlots = map[router.Intent][]dialogue.Slot{
|
||||
router.IntentReminder: {dialogue.SlotText, dialogue.SlotTime},
|
||||
router.IntentFact: {dialogue.SlotKey},
|
||||
@@ -140,7 +140,8 @@ func missingFor(dec router.Decision) []dialogue.Slot {
|
||||
// ("", false) when she has no idea what is missing.
|
||||
//
|
||||
// One question about one thing: if two slots are missing she asks about the
|
||||
// first and lets the rest go. Two questions in a row is an interrogation.
|
||||
// first only. Two questions in one breath is an interrogation. The second gap
|
||||
// is picked up on the turn after the first one is answered (askRemainingGap).
|
||||
func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
||||
missing := missingFor(dec)
|
||||
if len(missing) == 0 {
|
||||
@@ -199,11 +200,27 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
intent := router.Intent(q.Intent)
|
||||
answer := h.extractor.Extract(ctx, intent, text, h.now())
|
||||
merged := q.Answer(text, toDialogueSlots(answer))
|
||||
// Fold a newly answered subject into the raw utterance. Downstream actions
|
||||
// phrase from Utterance, not from the text slot — actionReminder stores it
|
||||
// as the reminder payload — so a reminder clarified out of a bare "напомни"
|
||||
// would fire at 11:00 saying "напомни" and nothing else.
|
||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
|
||||
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
||||
return h.reaskOrGiveUp(q, merged, text), true
|
||||
}
|
||||
h.clarifyStore.Delete(voiceDialogueID)
|
||||
|
||||
// One gap filled is not the same as a complete request. askClarify parks
|
||||
// only the first gap, because one question per turn is the rule, but a
|
||||
// reminder wants both a subject and a time. "напомни" with neither used to
|
||||
// ask "О чём напомнить?", accept "позвонить маме", and then hand applyAction
|
||||
// a reminder with no time, which answered "не получилось разобрать время
|
||||
// напоминания." — an error for a request she never finished asking about.
|
||||
// Re-enter the loop instead, one question at a time as before.
|
||||
if reply, asked := h.askRemainingGap(q, intent, merged); asked {
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// Rebuild the decision as if it had routed cleanly, then run it down the
|
||||
// normal path. Clarify is deliberately false and the intent is unchanged:
|
||||
// filling in an argument never grants authority, so the completed decision
|
||||
@@ -218,6 +235,55 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
return h.finishClarified(ctx, dec), true
|
||||
}
|
||||
|
||||
// foldAnswerIntoUtterance appends an answered subject to the original words,
|
||||
// unless they already carry it. "напомни" + "позвонить маме" reads as the
|
||||
// request he would have made in one breath. Nothing is appended when the
|
||||
// subject is empty or already present, so re-asking the same question twice
|
||||
// cannot grow the utterance.
|
||||
func foldAnswerIntoUtterance(utterance, subject string) string {
|
||||
subject = strings.TrimSpace(subject)
|
||||
if subject == "" || strings.Contains(utterance, subject) {
|
||||
return utterance
|
||||
}
|
||||
if strings.TrimSpace(utterance) == "" {
|
||||
return subject
|
||||
}
|
||||
return strings.TrimSpace(utterance) + " " + subject
|
||||
}
|
||||
|
||||
// askRemainingGap re-parks the request when the answer closed one gap and
|
||||
// wantedSlots still names another. Returns ("", false) when the request is
|
||||
// complete, when there is no question for what is left, or when she is out of
|
||||
// attempts — in all three the caller runs the decision as it stands, which for
|
||||
// the out-of-attempts case is the old behaviour and is the right one: she has
|
||||
// already asked enough.
|
||||
//
|
||||
// The attempt budget is shared with the re-ask path on purpose. A second gap
|
||||
// costs a question exactly like a second try at the first one does, so the cap
|
||||
// still bounds how many times she can speak before acting or letting go.
|
||||
func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
||||
remaining := dialogue.StillMissing(wantedSlots[intent], merged)
|
||||
if len(remaining) == 0 {
|
||||
return "", false
|
||||
}
|
||||
question, ok := clarifyQuestions[remaining[0]]
|
||||
if !ok || !q.CanAsk() {
|
||||
return "", false
|
||||
}
|
||||
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
|
||||
Intent: q.Intent,
|
||||
Slots: merged,
|
||||
Missing: []dialogue.Slot{remaining[0]},
|
||||
Utterance: q.Utterance,
|
||||
Asked: h.now(),
|
||||
TTL: clarifyTTL,
|
||||
Attempts: q.Attempts + 1,
|
||||
MaxAttempts: q.MaxAttempts,
|
||||
})
|
||||
log.Printf("voice: clarify — one gap filled, still missing %s for intent=%s, asking again (attempt %d)", remaining[0], intent, q.Attempts+1)
|
||||
return question, true
|
||||
}
|
||||
|
||||
// reaskOrGiveUp handles an answer that left the gap open: ask the same question
|
||||
// again while she has attempts left, otherwise say she did not understand and
|
||||
// let the request go. Never returns "" — a mute give-up reads as "done".
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser/eval"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
@@ -330,3 +331,137 @@ func TestNoPendingQuestionFallsThrough(t *testing.T) {
|
||||
t.Fatalf("no open question ⇒ must not be treated as an answer, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyAsksAboutTheSecondGapToo — "напомни" with neither a subject nor a
|
||||
// time. She asks about the subject, he gives it, and the request is still not
|
||||
// complete. The old code handed applyAction a reminder with no time, which
|
||||
// answered with a parse error for a question she never asked.
|
||||
func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
||||
if !asked || question != "О чём напомнить?" {
|
||||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||||
}
|
||||
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||||
if !handled {
|
||||
t.Fatal("the answer must be consumed as an answer")
|
||||
}
|
||||
if reply != "Когда?" {
|
||||
t.Fatalf("a filled subject with no time must ask about the time, got %q", reply)
|
||||
}
|
||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||
if q == nil {
|
||||
t.Fatal("the second gap must leave a question armed")
|
||||
}
|
||||
if q.Slots.Text == "" {
|
||||
t.Fatalf("the re-parked question lost the answered subject: %+v", q.Slots)
|
||||
}
|
||||
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
t.Fatalf("the time answer must complete the reminder, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||||
if err != nil || len(reminders) != 1 {
|
||||
t.Fatalf("expected one reminder: %v err=%v", reminders, err)
|
||||
}
|
||||
if !strings.Contains(reminders[0].Payload, "маме") {
|
||||
t.Fatalf("the reminder lost the subject: %q", reminders[0].Payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifySecondGapRespectsTheAttemptCap — the second gap spends a question
|
||||
// out of the same budget, so it cannot turn a capped exchange into an endless
|
||||
// one. With one attempt allowed she acts on what she has instead of asking.
|
||||
func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h.clarifyMaxAttempts = 1
|
||||
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
||||
t.Fatal("expected the subject question")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||||
if !handled {
|
||||
t.Fatal("the answer must be consumed")
|
||||
}
|
||||
if reply == "Когда?" {
|
||||
t.Fatal("out of attempts she must not ask a second question")
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Fatal("no question may stay armed past the cap")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyProseHoldsThePersona — these lines are hand-written Russian that
|
||||
// the phrasing eval never sees, because they never go through the phraser. They
|
||||
// carry feminine self-reference ("ждала", "отпустила") and address him with a
|
||||
// plain imperative, and they are exactly the kind of string someone later edits
|
||||
// reaching for a synonym. Run the eval's own persona checks over them here.
|
||||
func TestClarifyProseHoldsThePersona(t *testing.T) {
|
||||
// Only the persona checks. Length and on-topic do not apply: these are not
|
||||
// nudges, they have no rule to be on topic about, and the expiry lines are
|
||||
// deliberately longer than a nudge ceiling.
|
||||
want := map[string]bool{
|
||||
eval.CheckFeminine: true,
|
||||
eval.CheckHisGender: true,
|
||||
eval.CheckAddress: true,
|
||||
eval.CheckCringe: true,
|
||||
}
|
||||
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
|
||||
for _, q := range clarifyQuestions {
|
||||
lines = append(lines, q)
|
||||
}
|
||||
for _, line := range lines {
|
||||
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
|
||||
// The apology clause of the cringe check is scoped to nudges: it
|
||||
// exists because apologising for a greenlit nudge undermines it.
|
||||
// These lines are the opposite case. She did not understand him, or
|
||||
// she let his request go, and "прости" there is ordinary speech
|
||||
// rather than grovelling. Every other cringe rule still applies:
|
||||
// pet names, emoji, exclamations, fake concern, praise.
|
||||
// checkCringe returns the first break it finds, so this skip also
|
||||
// hides a later one in the same line. Kept narrow on purpose: it
|
||||
// only fires on a leading "apology (…)" detail.
|
||||
if r.Name == eval.CheckCringe && strings.HasPrefix(r.Detail, "apology") {
|
||||
continue
|
||||
}
|
||||
if want[r.Name] && !r.Pass {
|
||||
t.Errorf("%q fails %s: %s", line, r.Name, r.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpiryNoticeSurvivesAConfirmTurn — she asks a question, he walks off, the
|
||||
// question expires, he comes back and answers a confirm that is still parked.
|
||||
// The confirm turn used to return before the notice was even computed, so he
|
||||
// answered the confirm and never heard that the older request was let go.
|
||||
func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _, now := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
// A confirm parked with a longer life than the question, so only the
|
||||
// question is stale when he speaks.
|
||||
h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)}
|
||||
*now = now.Add(clarifyTTL + time.Second)
|
||||
|
||||
reply := h.handleText(ctx, "нет")
|
||||
if !isClarifyExpired(reply) {
|
||||
t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply)
|
||||
}
|
||||
if trimClarifyExpired(reply) == "" {
|
||||
t.Fatalf("the confirm answer must survive the notice, got only the notice: %q", reply)
|
||||
}
|
||||
if h.pending != nil {
|
||||
t.Fatal("the confirm must still have been consumed")
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Fatal("the expired question must be gone")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
func randBytes(t *testing.T, n int) []byte {
|
||||
t.Helper()
|
||||
b := make([]byte, n)
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
b[0] |= 1
|
||||
return b
|
||||
}
|
||||
|
||||
func TestDaemonLockStartsLockedAndFlips(t *testing.T) {
|
||||
dl := newDaemonLock(true)
|
||||
if !dl.isLocked() {
|
||||
t.Fatal("newDaemonLock(true) is not locked")
|
||||
}
|
||||
dl.unlock(nil)
|
||||
if dl.isLocked() {
|
||||
t.Fatal("still locked after unlock")
|
||||
}
|
||||
if newDaemonLock(false).isLocked() {
|
||||
t.Fatal("newDaemonLock(false) reports locked")
|
||||
}
|
||||
}
|
||||
|
||||
// closeStore must be safe on a daemon that never unlocked and safe twice —
|
||||
// shutdown runs it unconditionally.
|
||||
func TestDaemonLockCloseStoreIsSafeWhenNeverUnlocked(t *testing.T) {
|
||||
dl := newDaemonLock(true)
|
||||
if err := dl.closeStore(); err != nil {
|
||||
t.Fatalf("closeStore with no store: %v", err)
|
||||
}
|
||||
if err := dl.closeStore(); err != nil {
|
||||
t.Fatalf("second closeStore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The data-loss bug: in locked mode the store is opened on an IPC goroutine
|
||||
// inside UnlockFn, and shutdown runs on main. Without the handoff nothing
|
||||
// calls Close, and Close is what re-encrypts the tmpfs working copy back over
|
||||
// the ciphertext file — so every write of a cold-started session vanished.
|
||||
func TestDaemonLockSealsTheStoreOpenedAfterUnlock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "maven.db")
|
||||
tmpfs := filepath.Join(dir, "work")
|
||||
key := randBytes(t, 32)
|
||||
// Store.Close zeroes the key slice it was handed (encState.key is the
|
||||
// caller's backing array), so the next boot needs its own copy — exactly
|
||||
// as mavend keeps envKeyBytes separate from the config's key.
|
||||
nextBoot := bytes.Clone(key)
|
||||
ctx := context.Background()
|
||||
|
||||
// Cold start: locked, no store.
|
||||
dl := newDaemonLock(true)
|
||||
|
||||
// ... unlock arrives, opens the store and hands it over.
|
||||
st, err := store.OpenEncrypted(ctx, dbPath, tmpfs, key)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenEncrypted: %v", err)
|
||||
}
|
||||
dl.unlock(st)
|
||||
if _, err := st.WriteNote(ctx, time.Now(), "заметка после холодного старта", nil, "test"); err != nil {
|
||||
t.Fatalf("WriteNote: %v", err)
|
||||
}
|
||||
|
||||
// Shutdown.
|
||||
if err := dl.closeStore(); err != nil {
|
||||
t.Fatalf("closeStore: %v", err)
|
||||
}
|
||||
if err := dl.closeStore(); err != nil {
|
||||
t.Fatalf("second closeStore after a real store: %v", err)
|
||||
}
|
||||
|
||||
// Next boot with the same key must see the write.
|
||||
st2, err := store.OpenEncrypted(ctx, dbPath, tmpfs, nextBoot)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer st2.Close()
|
||||
notes, err := st2.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("got %d notes after a cold-started session, want 1 — the session was lost", len(notes))
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the wrapped blob: what sits in the state dir must not let
|
||||
// anyone open the database. Nothing written there may contain the key, and the
|
||||
// ciphertext must not be readable with a wrong one.
|
||||
func TestColdStartLeavesNoPlaintextKeyOnDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "maven.db")
|
||||
tmpfs := filepath.Join(dir, "work")
|
||||
wrappedPath := filepath.Join(dir, "db_key.wrapped")
|
||||
key := randBytes(t, 32)
|
||||
secret := randBytes(t, 32)
|
||||
ctx := context.Background()
|
||||
|
||||
blob, err := webauthn.WrapKey(key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapKey: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(wrappedPath, blob, 0o600); err != nil {
|
||||
t.Fatalf("write wrapped key: %v", err)
|
||||
}
|
||||
|
||||
st, err := store.OpenEncrypted(ctx, dbPath, tmpfs, key)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenEncrypted: %v", err)
|
||||
}
|
||||
if _, err := st.WriteNote(ctx, time.Now(), "секрет", nil, "test"); err != nil {
|
||||
t.Fatalf("WriteNote: %v", err)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
// Walk everything in the state dir; none of it may contain the key.
|
||||
err = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
b, rerr := os.ReadFile(p)
|
||||
if rerr != nil {
|
||||
return nil // unreadable is not a leak
|
||||
}
|
||||
if bytes.Contains(b, key) {
|
||||
t.Errorf("%s contains the plaintext encryption key", p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
|
||||
// The wrapped file must have owner-only permissions.
|
||||
fi, err := os.Stat(wrappedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("wrapped key file mode = %o, want 600", perm)
|
||||
}
|
||||
|
||||
// A wrong passkey must not open the store.
|
||||
if _, _, err := webauthn.UnwrapKey(blob, randBytes(t, 32)); err == nil {
|
||||
t.Fatal("a wrong PRF secret unwrapped the key")
|
||||
}
|
||||
if _, err := store.OpenEncrypted(ctx, dbPath, filepath.Join(dir, "work2"), randBytes(t, 32)); err == nil {
|
||||
t.Fatal("the encrypted store opened under a wrong key")
|
||||
}
|
||||
|
||||
// And the right one round-trips back to a readable database.
|
||||
got, version, err := webauthn.UnwrapKey(blob, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("UnwrapKey: %v", err)
|
||||
}
|
||||
if version != webauthn.BlobV2 {
|
||||
t.Errorf("blob version = %v, want v2", version)
|
||||
}
|
||||
st2, err := store.OpenEncrypted(ctx, dbPath, tmpfs, got)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen with the unwrapped key: %v", err)
|
||||
}
|
||||
defer st2.Close()
|
||||
notes, err := st2.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("got %d notes, want 1", len(notes))
|
||||
}
|
||||
}
|
||||
+44
-9
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+129
-3
@@ -2,13 +2,16 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// planAPI answers only DayPlan; every other call is unimplemented, which is
|
||||
@@ -85,12 +88,40 @@ func TestQueryDayPlanTrimsToRestOfDay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// "что дальше?" after the last item of the day. The day was not empty, it is
|
||||
// over, and the whole-day empty line says something false about a day he just
|
||||
// lived through.
|
||||
func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) {
|
||||
plan := samplePlan()
|
||||
h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time {
|
||||
return time.Date(2026, 8, 3, 23, 0, 0, 0, time.UTC)
|
||||
}}
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the plan source to claim it")
|
||||
}
|
||||
if strings.Contains(reply, plan.Date.Format("02.01.2006")) {
|
||||
t.Errorf("the day had things on it and they are done, not empty: %q", reply)
|
||||
}
|
||||
if reply != "на сегодня больше ничего не запланировано." {
|
||||
t.Errorf("reply = %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A question that is not about the plan must fall through, or the plan buries
|
||||
// the calendar listing and the weather behind it.
|
||||
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
|
||||
for _, q := range []string{
|
||||
"что у меня сегодня?",
|
||||
"какие планы на завтра?",
|
||||
// The plan can only be built for the clock's own day. Naming another
|
||||
// one has to fall through, not get answered with today.
|
||||
"какие планы на понедельник?",
|
||||
"какие планы на неделю?",
|
||||
"какие планы на выходные?",
|
||||
"what are my plans for friday?",
|
||||
"когда планёрка?",
|
||||
"какая погода?",
|
||||
"",
|
||||
@@ -142,17 +173,21 @@ func TestDayPlanSourcePrecedesCalendar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// habitAPI answers only RecentFacts — the whole input the behaviour profile
|
||||
// needs (Vikunja #254). Nothing is asked of the LLM, so nothing else is wired.
|
||||
// habitAPI answers only the kind-filtered fact read — the whole input the
|
||||
// behaviour profile needs (Vikunja #254). Nothing is asked of the LLM, so
|
||||
// nothing else is wired. RecentFacts is left unimplemented on purpose: the
|
||||
// profile must not read the mixed window, and a caller that does fails here.
|
||||
type habitAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
facts []ipc.Fact
|
||||
err error
|
||||
calls int
|
||||
kind string
|
||||
}
|
||||
|
||||
func (a *habitAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) {
|
||||
func (a *habitAPI) RecentActiveFactsByKind(_ context.Context, kind string, _ int) ([]ipc.Fact, error) {
|
||||
a.calls++
|
||||
a.kind = kind
|
||||
return a.facts, a.err
|
||||
}
|
||||
|
||||
@@ -225,3 +260,94 @@ func TestHabitSourcePrecedesCalendar(t *testing.T) {
|
||||
t.Errorf("habits at %d must come before calendar at %d", habits, cal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryHabitsReadsSelfFactsOnly — the profile window is a budget over rows,
|
||||
// so it must be spent on the rows the profile can use. Reading the mixed table
|
||||
// let one chatty poller (wg_handshake, roughly every two minutes per peer) push
|
||||
// every tap out of the window, and she then reported no habits on a store that
|
||||
// held them.
|
||||
func TestQueryHabitsReadsSelfFactsOnly(t *testing.T) {
|
||||
now := planDay()
|
||||
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||
|
||||
if _, ok := h.queryHabits(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что я обычно делаю по вторникам?"},
|
||||
}); !ok {
|
||||
t.Fatal("the habit source must claim a habit question")
|
||||
}
|
||||
if api.kind != string(store.KindSelf) {
|
||||
t.Errorf("profile read kind %q, want %q", api.kind, store.KindSelf)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHabitQueryWithPlanWordReachesHabits — the whole chain, not just the
|
||||
// matchers: a habit question carrying "планы" used to be answered by the day
|
||||
// plan with today's calendar, because day-plan sits above habits.
|
||||
func TestHabitQueryWithPlanWordReachesHabits(t *testing.T) {
|
||||
now := planDay()
|
||||
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||
|
||||
reply := h.actionQuery(context.Background(), router.Decision{
|
||||
Intent: router.IntentQuery,
|
||||
Utterance: "какие у меня обычно планы по вторникам?",
|
||||
})
|
||||
if want := "по вторникам ты обычно тренируешься около 19:00."; reply != want {
|
||||
t.Errorf("reply = %q, want %q", reply, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The plan reads the store on the owner's clock: one line per event, the hour
|
||||
// printed once, and reminders selected by fire time rather than by how
|
||||
// recently they were stated.
|
||||
func TestTickDayPlanReadsTheStore(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||
|
||||
now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.Local)
|
||||
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.Local)
|
||||
ev := calendar.Event{
|
||||
Summary: "Standup",
|
||||
Start: day.Add(14 * time.Hour),
|
||||
End: day.Add(14*time.Hour + 30*time.Minute),
|
||||
}
|
||||
// Rescheduled: same key, a second row.
|
||||
if _, err := st.WriteFact(ctx, ev.Start, store.KindEnv, calendar.FactKey(ev),
|
||||
calendar.FactValue(ev), calendar.SourcePersonal, 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFact: %v", err)
|
||||
}
|
||||
moved := ev
|
||||
moved.Start, moved.End = day.Add(16*time.Hour), day.Add(16*time.Hour+30*time.Minute)
|
||||
if _, err := st.WriteFact(ctx, moved.Start, store.KindEnv, calendar.FactKey(moved),
|
||||
calendar.FactValue(moved), calendar.SourcePersonal, 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFact: %v", err)
|
||||
}
|
||||
// One reminder today, one next year. Both are pending; only today's is a
|
||||
// plan for today.
|
||||
if _, err := st.CreateReminder(ctx, day.Add(18*time.Hour), "позвонить маме", ""); err != nil {
|
||||
t.Fatalf("CreateReminder: %v", err)
|
||||
}
|
||||
if _, err := st.CreateReminder(ctx, day.AddDate(1, 0, 0), "продлить страховку", ""); err != nil {
|
||||
t.Fatalf("CreateReminder: %v", err)
|
||||
}
|
||||
|
||||
plan := tl.dayPlan(ctx, now)
|
||||
if len(plan.Items) != 2 {
|
||||
t.Fatalf("got %d items, want the moved standup and today's reminder: %+v", len(plan.Items), plan.Items)
|
||||
}
|
||||
ev0 := plan.Items[0]
|
||||
if ev0.Kind != "event" || ev0.At.In(time.Local).Format("15:04") != "16:00" {
|
||||
t.Errorf("event = %+v, want the 16:00 one", ev0)
|
||||
}
|
||||
if ev0.Text != "Standup" {
|
||||
t.Errorf("text = %q — the plan prints the hour itself", ev0.Text)
|
||||
}
|
||||
if plan.Items[1].Text != "позвонить маме" {
|
||||
t.Errorf("second item = %+v", plan.Items[1])
|
||||
}
|
||||
if strings.Contains(plan.Spoken, "страховку") {
|
||||
t.Errorf("a reminder for next year is not today's plan: %q", plan.Spoken)
|
||||
}
|
||||
}
|
||||
|
||||
+152
-57
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -31,18 +32,84 @@ func correlationIDFromCtx(ctx context.Context) string {
|
||||
return id
|
||||
}
|
||||
|
||||
// setEcosystemHeaders stamps the version and correlation headers common to
|
||||
// every outgoing ecosystem request.
|
||||
func setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader string) {
|
||||
// ecosystemAPIVersion is the contract version Maven speaks to Nexus and
|
||||
// Praxis. It is sent on every request so a service that has moved on can
|
||||
// refuse or adapt explicitly instead of misreading an older payload.
|
||||
const ecosystemAPIVersion = "v1"
|
||||
|
||||
// mavenRequester identifies the calling system on every ecosystem request, so
|
||||
// a trace on the far side can attribute a call to Maven rather than to an
|
||||
// anonymous HTTP client.
|
||||
const mavenRequester = "maven"
|
||||
|
||||
// setEcosystemHeaders stamps the version, requester, auth and correlation
|
||||
// headers common to every outgoing ecosystem request. token may be empty,
|
||||
// which means the transport itself is trusted (loopback or unix socket).
|
||||
//
|
||||
// The correlation ID is read from the context and never minted here. Minting
|
||||
// one per request sent the far side an ID that existed nowhere on this side,
|
||||
// and gave a single multi-hop action as many unrelated IDs as it made calls.
|
||||
// Callers that start an action assign the ID once (handleHexisAct,
|
||||
// handlePraxisAct, resolveEntityReference) and every hop inherits it.
|
||||
func setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader, token string) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(versionHeader, "v1")
|
||||
req.Header.Set(versionHeader, ecosystemAPIVersion)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Requested-By", mavenRequester)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if id := correlationIDFromCtx(ctx); id != "" {
|
||||
req.Header.Set("X-Correlation-ID", id)
|
||||
}
|
||||
}
|
||||
|
||||
// ecosystemError is the typed failure every ecosystem client returns, so
|
||||
// callers can tell a transport failure from a refusal from a contract
|
||||
// mismatch without matching on message text. The distinction matters:
|
||||
// "the service is down" and "the service rejected my version" degrade the
|
||||
// same way to the user but not to whoever reads the trace.
|
||||
type ecosystemError struct {
|
||||
Service string // "nexus", "praxis", "hexis"
|
||||
Op string // logical operation, e.g. "resolve"
|
||||
Status int // HTTP status, 0 when the call never got an answer
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ecosystemError) Error() string {
|
||||
if e.Status != 0 {
|
||||
return fmt.Sprintf("%s %s: http %d: %v", e.Service, e.Op, e.Status, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("%s %s: %v", e.Service, e.Op, e.Err)
|
||||
}
|
||||
|
||||
func (e *ecosystemError) Unwrap() error { return e.Err }
|
||||
|
||||
// Unauthorized reports a rejected or missing credential.
|
||||
func (e *ecosystemError) Unauthorized() bool {
|
||||
return e.Status == http.StatusUnauthorized || e.Status == http.StatusForbidden
|
||||
}
|
||||
|
||||
// ContractMismatch reports that the far side refused the version Maven speaks.
|
||||
func (e *ecosystemError) ContractMismatch() bool {
|
||||
return e.Status == http.StatusNotAcceptable || e.Status == http.StatusUpgradeRequired
|
||||
}
|
||||
|
||||
// Unreachable reports a call that never produced an HTTP answer at all
|
||||
// (connection refused, timeout, cancelled).
|
||||
func (e *ecosystemError) Unreachable() bool { return e.Status == 0 }
|
||||
|
||||
// httpError builds an ecosystemError from a response status.
|
||||
func httpError(service, op string, status int) *ecosystemError {
|
||||
return &ecosystemError{
|
||||
Service: service, Op: op, Status: status,
|
||||
Err: errors.New(http.StatusText(status)),
|
||||
}
|
||||
}
|
||||
|
||||
type nexusClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
@@ -53,6 +120,13 @@ func newNexusClient(url string) *nexusClient {
|
||||
}
|
||||
}
|
||||
|
||||
// withToken sets the bearer token sent on every request. Returns the client so
|
||||
// wiring reads as one expression.
|
||||
func (c *nexusClient) withToken(token string) *nexusClient {
|
||||
c.token = token
|
||||
return c
|
||||
}
|
||||
|
||||
type nexusEntity struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
@@ -107,22 +181,22 @@ func (c *nexusClient) Resolve(ctx context.Context, query string, types []string)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version")
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("do request: %w", err)
|
||||
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("nexus: %s", http.StatusText(resp.StatusCode))
|
||||
return nil, httpError("nexus", "resolve", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result nexusResolveResult
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
@@ -130,16 +204,16 @@ func (c *nexusClient) Resolve(ctx context.Context, query string, types []string)
|
||||
func (c *nexusClient) Health(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version")
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("nexus health: %s", http.StatusText(resp.StatusCode))
|
||||
return httpError("nexus", "health", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -149,6 +223,7 @@ func (c *nexusClient) Health(ctx context.Context) error {
|
||||
// so attention/changes/lifecycle all go over this HTTP contract against praxisd.
|
||||
type praxisClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
@@ -159,27 +234,38 @@ func newPraxisClient(url string) *praxisClient {
|
||||
}
|
||||
}
|
||||
|
||||
// getJSON performs a GET and decodes the JSON body into out.
|
||||
func (c *praxisClient) getJSON(ctx context.Context, path string, out any) error {
|
||||
func (c *praxisClient) withToken(token string) *praxisClient {
|
||||
c.token = token
|
||||
return c
|
||||
}
|
||||
|
||||
// getJSON performs a GET and decodes the JSON body into out. op is the logical
|
||||
// operation name for errors and traces: the path carries the query string, and
|
||||
// after entity scoping that means an entity id in every log line built from the
|
||||
// error, next to a trace that redacts far less than that.
|
||||
func (c *praxisClient) getJSON(ctx context.Context, op, path string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version")
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("praxis: %s", http.StatusText(resp.StatusCode))
|
||||
return httpError("praxis", op, resp.StatusCode)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
|
||||
err := c.getJSON(ctx, "attention", fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -189,13 +275,14 @@ func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[stri
|
||||
// instead of filtering the unscoped list client-side.
|
||||
func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/attention?limit=%d&entity_id=%s", limit, url.QueryEscape(entityID)), &out)
|
||||
err := c.getJSON(ctx, "attention_for_entity",
|
||||
fmt.Sprintf("/api/v1/tools/attention?limit=%d&entity_id=%s", limit, url.QueryEscape(entityID)), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *praxisClient) ListChanges(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/changes?limit=%d", limit), &out)
|
||||
err := c.getJSON(ctx, "changes", fmt.Sprintf("/api/v1/tools/changes?limit=%d", limit), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -221,24 +308,32 @@ type praxisItem struct {
|
||||
|
||||
// postItemAction posts {"item_id": id} to a Praxis tools lifecycle endpoint
|
||||
// and decodes the resulting item. Shared by Surface/Acknowledge/Resolve/Ignore.
|
||||
func (c *praxisClient) postItemAction(ctx context.Context, path, itemID string) (*praxisItem, error) {
|
||||
body, _ := json.Marshal(map[string]any{"item_id": itemID})
|
||||
func (c *praxisClient) postItemAction(ctx context.Context, op, path, itemID string) (*praxisItem, error) {
|
||||
return c.postJSON(ctx, op, path, map[string]any{"item_id": itemID})
|
||||
}
|
||||
|
||||
// postJSON posts a body to a Praxis lifecycle endpoint and decodes the item.
|
||||
// Every failure is a *ecosystemError, including the transport and decode ones:
|
||||
// these are the paths that mutate remote state, and the question worth
|
||||
// answering afterwards is whether the call never left or was refused.
|
||||
func (c *praxisClient) postJSON(ctx context.Context, op, path string, payload map[string]any) (*praxisItem, error) {
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version")
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("praxis %s: %s", path, http.StatusText(resp.StatusCode))
|
||||
return nil, httpError("praxis", op, resp.StatusCode)
|
||||
}
|
||||
var out praxisItem
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -247,46 +342,28 @@ func (c *praxisClient) postItemAction(ctx context.Context, path, itemID string)
|
||||
// ECOSYSTEM-SPEC.md §2.3). Callers that read attention aloud must call this, never
|
||||
// Acknowledge, so "I mentioned it" stays distinguishable from "you told me you saw it".
|
||||
func (c *praxisClient) Surface(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
return c.postItemAction(ctx, "/api/v1/tools/surface", itemID)
|
||||
return c.postItemAction(ctx, "surface", "/api/v1/tools/surface", itemID)
|
||||
}
|
||||
|
||||
func (c *praxisClient) Acknowledge(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
return c.postItemAction(ctx, "/api/v1/tools/acknowledge", itemID)
|
||||
return c.postItemAction(ctx, "acknowledge", "/api/v1/tools/acknowledge", itemID)
|
||||
}
|
||||
|
||||
func (c *praxisClient) Resolve(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
return c.postItemAction(ctx, "/api/v1/tools/resolve", itemID)
|
||||
return c.postItemAction(ctx, "resolve", "/api/v1/tools/resolve", itemID)
|
||||
}
|
||||
|
||||
func (c *praxisClient) Ignore(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
return c.postItemAction(ctx, "/api/v1/tools/ignore", itemID)
|
||||
return c.postItemAction(ctx, "ignore", "/api/v1/tools/ignore", itemID)
|
||||
}
|
||||
|
||||
func (c *praxisClient) Pin(ctx context.Context, itemID string, pinned bool) (*praxisItem, error) {
|
||||
body, _ := json.Marshal(map[string]any{"item_id": itemID, "pinned": pinned})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/tools/pin", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("praxis pin: %s", http.StatusText(resp.StatusCode))
|
||||
}
|
||||
var out praxisItem
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
return c.postJSON(ctx, "pin", "/api/v1/tools/pin", map[string]any{"item_id": itemID, "pinned": pinned})
|
||||
}
|
||||
|
||||
func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
var out praxisItem
|
||||
err := c.getJSON(ctx, "/api/v1/tools/items/"+itemID, &out)
|
||||
err := c.getJSON(ctx, "get_item", "/api/v1/tools/items/"+itemID, &out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -295,7 +372,7 @@ func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem,
|
||||
|
||||
func (c *praxisClient) Search(ctx context.Context, query string, limit int) ([]praxisItem, error) {
|
||||
var out []praxisItem
|
||||
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/search?q=%s&limit=%d", url.QueryEscape(query), limit), &out)
|
||||
err := c.getJSON(ctx, "search", fmt.Sprintf("/api/v1/tools/search?q=%s&limit=%d", url.QueryEscape(query), limit), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -311,7 +388,7 @@ func wireEcosystem(cfg *config.Config) *ecosystemWiring {
|
||||
|
||||
// Nexus identity service
|
||||
if cfg.Nexus != nil && cfg.Nexus.URL != "" {
|
||||
w.nexus = newNexusClient(cfg.Nexus.URL)
|
||||
w.nexus = newNexusClient(cfg.Nexus.URL).withToken(cfg.Nexus.Token)
|
||||
log.Printf("ecosystem: nexus at %s", cfg.Nexus.URL)
|
||||
} else {
|
||||
log.Printf("ecosystem: nexus not configured")
|
||||
@@ -319,7 +396,7 @@ func wireEcosystem(cfg *config.Config) *ecosystemWiring {
|
||||
|
||||
// Hexis capability service
|
||||
if cfg.Hexis != nil && cfg.Hexis.URL != "" {
|
||||
w.hexis = hexisclient.New(cfg.Hexis.URL)
|
||||
w.hexis = hexisclient.New(cfg.Hexis.URL).WithToken(cfg.Hexis.Token)
|
||||
log.Printf("ecosystem: hexis at %s", cfg.Hexis.URL)
|
||||
} else {
|
||||
log.Printf("ecosystem: hexis not configured")
|
||||
@@ -327,7 +404,7 @@ func wireEcosystem(cfg *config.Config) *ecosystemWiring {
|
||||
|
||||
// Praxis attention service (HTTP tools API — never the DB directly)
|
||||
if cfg.Praxis != nil && cfg.Praxis.URL != "" {
|
||||
w.praxis = newPraxisClient(cfg.Praxis.URL)
|
||||
w.praxis = newPraxisClient(cfg.Praxis.URL).withToken(cfg.Praxis.Token)
|
||||
log.Printf("ecosystem: praxis at %s", cfg.Praxis.URL)
|
||||
} else {
|
||||
log.Printf("ecosystem: praxis not configured")
|
||||
@@ -352,7 +429,19 @@ func (w *ecosystemWiring) resolveEntityReference(ctx context.Context, text strin
|
||||
log.Printf("ecosystem: nexus resolve error: %v", err)
|
||||
return "", "", nil, err
|
||||
}
|
||||
if result.Status == "resolved" && result.Entity != nil {
|
||||
if result.Status == "resolved" {
|
||||
// "resolved" with nothing to resolve to is a contract violation, not a
|
||||
// miss. Treating it as "no such entity" let the caller fall straight
|
||||
// through to the local executor with his verb intact, which is a
|
||||
// dependency failure reaching execution.
|
||||
if result.Entity == nil || result.Entity.ID == "" {
|
||||
err := &ecosystemError{
|
||||
Service: "nexus", Op: "resolve", Status: 200,
|
||||
Err: errors.New("resolved status with no entity"),
|
||||
}
|
||||
log.Printf("ecosystem: %v", err)
|
||||
return "", "", nil, err
|
||||
}
|
||||
return result.Entity.ID, result.Entity.DisplayName, nil, nil
|
||||
}
|
||||
if result.Status == "ambiguous" {
|
||||
@@ -372,6 +461,12 @@ func (w *ecosystemWiring) resolveEntityReference(ctx context.Context, text strin
|
||||
// healthy and genuinely has nothing registered for this entity. Callers must
|
||||
// not conflate the two: a dependency failure must not silently read as "no
|
||||
// capabilities" and fall through to unrelated local execution.
|
||||
//
|
||||
// The correlation header is stamped in the client's do(), so discovery and
|
||||
// execution can be joined on the Hexis side as long as both hops carry the
|
||||
// same ID through ctx. (This used to say the header went out on Execute only;
|
||||
// that was never true of the vendored code and is not true after the 2026-08-01
|
||||
// re-vendor.)
|
||||
func (w *ecosystemWiring) discoverCapabilities(ctx context.Context, entityID string) ([]hexisclient.Capability, error) {
|
||||
if w == nil || w.hexis == nil || entityID == "" {
|
||||
return nil, nil
|
||||
|
||||
+349
-27
@@ -2,14 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
hexisclient "github.com/kami/hexis/pkg/client"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// praxisCapability is one arm of the Praxis act dispatch. This is an interface
|
||||
@@ -72,6 +73,7 @@ var praxisCapabilities = []praxisCapability{
|
||||
},
|
||||
},
|
||||
listChangesCapability{},
|
||||
entityAttentionCapability{},
|
||||
}
|
||||
|
||||
// handlePraxisAct — dispatches ecosystem tool acts through the Praxis tools API.
|
||||
@@ -81,6 +83,12 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
|
||||
if h.ecosystem == nil || h.ecosystem.praxis == nil {
|
||||
return ""
|
||||
}
|
||||
// Every hop of this action shares one correlation ID, assigned here, so a
|
||||
// digest that calls attention once and surface N times reads as one turn
|
||||
// on the Praxis side instead of N+1 unrelated request ids.
|
||||
if correlationIDFromCtx(ctx) == "" {
|
||||
ctx = withCorrelationID(ctx, newCorrelationID())
|
||||
}
|
||||
px := h.ecosystem.praxis
|
||||
for _, capability := range praxisCapabilities {
|
||||
for _, alias := range capability.aliases() {
|
||||
@@ -111,11 +119,14 @@ func (a praxisItemAction) handle(ctx context.Context, h *reactiveHandler, px *pr
|
||||
if id == "" {
|
||||
return a.ask
|
||||
}
|
||||
started := h.now()
|
||||
if err := a.call(ctx, px, id); err != nil {
|
||||
log.Printf("ecosystem: praxis %s %s: %v", a.op, id, err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", a.op, traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"item_id": id}))
|
||||
return a.failure
|
||||
}
|
||||
h.recordPraxisTrace(ctx, a.op, map[string]any{"item_id": id})
|
||||
h.recordPraxisTrace(ctx, a.op, started, map[string]any{"item_id": id})
|
||||
return a.success
|
||||
}
|
||||
|
||||
@@ -127,15 +138,18 @@ func (listAttentionCapability) aliases() []string {
|
||||
}
|
||||
|
||||
func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
|
||||
started := h.now()
|
||||
items, err := px.ListAttention(ctx, 20)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis attention: %v", err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
|
||||
started, traceErrorFields(err))
|
||||
return "не могу сейчас узнать, что требует внимания."
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return "ничего не требует внимания."
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "list_attention", map[string]any{"count": len(items)})
|
||||
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
|
||||
var parts []string
|
||||
for _, item := range items {
|
||||
title, _ := item["title"].(string)
|
||||
@@ -172,15 +186,18 @@ func (listChangesCapability) aliases() []string {
|
||||
}
|
||||
|
||||
func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
|
||||
started := h.now()
|
||||
changes, err := px.ListChanges(ctx, 20)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis changes: %v", err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
|
||||
started, traceErrorFields(err))
|
||||
return "не могу сейчас узнать об изменениях."
|
||||
}
|
||||
if len(changes) == 0 {
|
||||
return "нет изменений."
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "list_changes", map[string]any{"count": len(changes)})
|
||||
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
|
||||
var parts []string
|
||||
for _, c := range changes {
|
||||
title, _ := c["title"].(string)
|
||||
@@ -190,25 +207,294 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
|
||||
return "изменения: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// recordPraxisTrace — writes a fact recording a cross-service ecosystem call.
|
||||
// The fact is stored with source "praxis:trace" so the proactive loop can
|
||||
// reference it and the dashboard can display recent ecosystem activity.
|
||||
func (h *reactiveHandler) recordPraxisTrace(ctx context.Context, operation string, details map[string]any) {
|
||||
now := h.now()
|
||||
value := operation
|
||||
if len(details) > 0 {
|
||||
if b, err := json.Marshal(details); err == nil {
|
||||
value = operation + " " + string(b)
|
||||
// entityAttentionCapability answers "what's going on with X" by resolving X to
|
||||
// a canonical Nexus entity and asking Praxis for that entity's attention items
|
||||
// (Vikunja #272). Unlike listAttentionCapability it is scoped: the entity_id
|
||||
// travels to Praxis as a query parameter instead of Maven filtering an unscoped
|
||||
// list client-side, which is what makes the ref canonical end to end.
|
||||
//
|
||||
// It also folds in what Maven herself knows about the same entity — facts the
|
||||
// enrichment worker has already resolved to that entity_id — so one question
|
||||
// gets one answer across both stores.
|
||||
type entityAttentionCapability struct{}
|
||||
|
||||
// aliases are matched against Slots.Fn, which carries a function slot from the
|
||||
// act grammar and never free Russian, so only grammar names belong here.
|
||||
func (entityAttentionCapability) aliases() []string {
|
||||
return []string{"entity_attention", "entity_status"}
|
||||
}
|
||||
|
||||
func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, dec router.Decision) string {
|
||||
subject := dec.Slots.Value
|
||||
if subject == "" {
|
||||
subject = dec.Slots.Text
|
||||
}
|
||||
if subject == "" {
|
||||
return "про что именно спросить?"
|
||||
}
|
||||
if h.ecosystem == nil || h.ecosystem.nexus == nil {
|
||||
// Without Nexus there is no canonical ref to scope by. Say so rather
|
||||
// than quietly answering about something else.
|
||||
return "не могу связать это с сущностью — Nexus не настроен."
|
||||
}
|
||||
|
||||
started := h.now()
|
||||
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, subject, nil)
|
||||
if err != nil {
|
||||
// The subject is his words, so the log gets the same redaction the
|
||||
// trace gets. A trace that stores a rune count next to a log line
|
||||
// storing the runes is not redacted at all.
|
||||
log.Printf("ecosystem: entity attention resolve %s: %v", redactSubject(subject), err)
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
}
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||||
}
|
||||
if entityID == "" {
|
||||
return "не знаю такой сущности."
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = subject
|
||||
}
|
||||
|
||||
queried := h.now()
|
||||
items, err := px.ListAttentionForEntity(ctx, entityID, 20)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
|
||||
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
||||
}
|
||||
items, scoped := scopedToEntity(items, entityID)
|
||||
if !scoped {
|
||||
// A Praxis old enough to ignore an unknown query parameter answers the
|
||||
// scoped question with the unscoped list. Reading that back as "по
|
||||
// «X»: ..." is the exact fabrication the entity ref exists to prevent,
|
||||
// so refuse the answer instead of relabelling someone else's items.
|
||||
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
|
||||
map[string]any{"entity_id": entityID, "class": "unscoped_response"})
|
||||
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
|
||||
"entity_id": entityID, "count": len(items),
|
||||
})
|
||||
|
||||
var parts []string
|
||||
for _, item := range items {
|
||||
title, _ := item["title"].(string)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, title)
|
||||
// Same surfaced != acknowledged rule as the unscoped digest.
|
||||
if id, ok := item["id"].(string); ok && id != "" {
|
||||
if _, err := px.Surface(ctx, id); err != nil {
|
||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
_, _ = h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: now,
|
||||
Kind: "system",
|
||||
Key: "praxis:" + operation,
|
||||
Value: value,
|
||||
Source: "praxis:trace",
|
||||
Confidence: 1.0,
|
||||
})
|
||||
if known := h.localFactsForEntity(ctx, entityID); known != "" {
|
||||
parts = append(parts, known)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "по «" + displayName + "» ничего нет."
|
||||
}
|
||||
return "по «" + displayName + "»: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// scopedToEntity drops items that carry an entity_id other than the one asked
|
||||
// about, and reports whether the response can be trusted as scoped at all. An
|
||||
// item without an entity_id is kept only when at least one sibling carries the
|
||||
// matching id: a whole page with no entity_id is a Praxis that ignored the
|
||||
// scope, not a page of untagged items.
|
||||
func scopedToEntity(items []map[string]any, entityID string) ([]map[string]any, bool) {
|
||||
if len(items) == 0 {
|
||||
return items, true
|
||||
}
|
||||
var kept []map[string]any
|
||||
var sawMatch, sawMismatch bool
|
||||
for _, item := range items {
|
||||
id, _ := item["entity_id"].(string)
|
||||
switch {
|
||||
case id == entityID:
|
||||
sawMatch = true
|
||||
kept = append(kept, item)
|
||||
case id != "":
|
||||
sawMismatch = true
|
||||
default:
|
||||
kept = append(kept, item)
|
||||
}
|
||||
}
|
||||
if sawMatch {
|
||||
return kept, true
|
||||
}
|
||||
if sawMismatch {
|
||||
// Some items were tagged and none matched: the far side answered about
|
||||
// other entities, so nothing here belongs to this one.
|
||||
return nil, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// localFactsForEntity summarises Maven's own facts already resolved to this
|
||||
// canonical entity. Empty when the store is unavailable or nothing matched —
|
||||
// entity-scoped memory is an enrichment of the answer, never a precondition.
|
||||
func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID string) string {
|
||||
if h.dataStore == nil || entityID == "" {
|
||||
return ""
|
||||
}
|
||||
const spoken = 3
|
||||
// One over the spoken limit, so a truncation can be named rather than
|
||||
// passed off as everything she knows.
|
||||
facts, err := h.dataStore.FactsByEntity(ctx, entityID, spoken+1)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: facts by entity %s: %v", entityID, err)
|
||||
return ""
|
||||
}
|
||||
more := false
|
||||
if len(facts) > spoken {
|
||||
facts, more = facts[:spoken], true
|
||||
}
|
||||
var parts []string
|
||||
for _, f := range facts {
|
||||
if f.Value != "" {
|
||||
parts = append(parts, f.Value)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := "я помню: " + strings.Join(parts, ", ")
|
||||
if more {
|
||||
out += ", и это не всё"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeFields overlays b onto a and returns a.
|
||||
func mergeFields(a, b map[string]any) map[string]any {
|
||||
for k, v := range b {
|
||||
a[k] = v
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// recordPraxisTrace — records a completed Praxis call. Thin wrapper over
|
||||
// recordEcosystemTrace so every ecosystem hop lands in one table with one
|
||||
// shape.
|
||||
func (h *reactiveHandler) recordPraxisTrace(ctx context.Context, operation string, started time.Time, details map[string]any) {
|
||||
h.recordEcosystemTrace(ctx, "praxis", operation, traceOK, started, details)
|
||||
}
|
||||
|
||||
// traceStatus classifies an ecosystem call for the trace record. Kept coarse
|
||||
// on purpose: a trace is read to answer "did this hop work, and how long did
|
||||
// it take", not to re-derive the error.
|
||||
const (
|
||||
traceOK = "ok"
|
||||
traceFailed = "failed" // the call never got an answer
|
||||
traceRefused = "refused" // the far side answered, and said no
|
||||
traceAmbig = "ambiguous"
|
||||
traceNotFound = "not_found"
|
||||
tracePending = "pending" // deliberately not done yet, awaiting a confirm
|
||||
)
|
||||
|
||||
// traceStatusForError distinguishes "I could not reach it" from "it answered
|
||||
// and refused". Both degrade the same way for him and not at all the same way
|
||||
// for whoever reads the trace: one is a network or a dead service, the other
|
||||
// is a token, a version or a rejected argument.
|
||||
func traceStatusForError(err error) string {
|
||||
var ee *ecosystemError
|
||||
if errors.As(err, &ee) && !ee.Unreachable() {
|
||||
return traceRefused
|
||||
}
|
||||
return traceFailed
|
||||
}
|
||||
|
||||
// redactSubject reduces a user utterance to something safe to persist in a
|
||||
// trace: its length only. Traces are diagnostics, and his words are not
|
||||
// diagnostics — the correlation ID is what ties a trace to the turn.
|
||||
func redactSubject(s string) string {
|
||||
return fmt.Sprintf("<%d chars>", len([]rune(s)))
|
||||
}
|
||||
|
||||
// recordEcosystemTrace writes one hop of a cross-service call: which service,
|
||||
// which operation, the outcome, how long it took, and the correlation ID that
|
||||
// stitches the hops together. It is written for every outcome, not only
|
||||
// success — an unrecorded failure is exactly the hop you need when something
|
||||
// went wrong at 3am.
|
||||
//
|
||||
// Traces go to their own store table, never to facts. One act turn produces
|
||||
// three or four of them, at machine rate, while facts arrive at human rate:
|
||||
// sharing the table meant the habit profile's 2000-row window, memeval's
|
||||
// prompt snapshot and the /dash and /history pages all filled with traces and
|
||||
// stopped seeing his actual facts.
|
||||
func (h *reactiveHandler) recordEcosystemTrace(ctx context.Context, service, op, status string, started time.Time, fields map[string]any) {
|
||||
if h.dataStore == nil {
|
||||
return
|
||||
}
|
||||
tr := store.EcosystemTrace{
|
||||
Ts: h.now(),
|
||||
Service: service,
|
||||
Operation: op,
|
||||
Status: status,
|
||||
DurationMs: h.now().Sub(started).Milliseconds(),
|
||||
CorrelationID: correlationIDFromCtx(ctx),
|
||||
Fields: map[string]any{},
|
||||
}
|
||||
for k, v := range fields {
|
||||
switch k {
|
||||
case "causation_id":
|
||||
tr.CausationID, _ = v.(string)
|
||||
case "http_status":
|
||||
if n, ok := v.(int); ok {
|
||||
tr.HTTPStatus = n
|
||||
continue
|
||||
}
|
||||
tr.Fields[k] = v
|
||||
default:
|
||||
tr.Fields[k] = v
|
||||
}
|
||||
}
|
||||
if _, err := h.dataStore.WriteEcosystemTrace(ctx, tr); err != nil {
|
||||
log.Printf("ecosystem: record trace %s:%s: %v", service, op, err)
|
||||
}
|
||||
}
|
||||
|
||||
// unauthorizedEcosystemError reports a credential the far side rejected. It
|
||||
// gets its own reply: a missing or wrong token looks exactly like an outage to
|
||||
// him, and "try again" is advice that will never work.
|
||||
func unauthorizedEcosystemError(err error) bool {
|
||||
var ee *ecosystemError
|
||||
return errors.As(err, &ee) && ee.Unauthorized()
|
||||
}
|
||||
|
||||
// traceErrorFields describes an ecosystemError for a trace without leaking the
|
||||
// payload: the HTTP status and the failure class, nothing else.
|
||||
func traceErrorFields(err error) map[string]any {
|
||||
fields := map[string]any{}
|
||||
var ee *ecosystemError
|
||||
if errors.As(err, &ee) {
|
||||
fields["http_status"] = ee.Status
|
||||
switch {
|
||||
case ee.Unauthorized():
|
||||
fields["class"] = "unauthorized"
|
||||
case ee.ContractMismatch():
|
||||
fields["class"] = "contract_mismatch"
|
||||
case ee.Unreachable():
|
||||
fields["class"] = "unreachable"
|
||||
default:
|
||||
fields["class"] = "error"
|
||||
}
|
||||
return fields
|
||||
}
|
||||
fields["class"] = "error"
|
||||
return fields
|
||||
}
|
||||
|
||||
// handleHexisAct — resolves entity references through Nexus and executes
|
||||
@@ -219,10 +505,23 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
return ""
|
||||
}
|
||||
|
||||
// Every hop of this action shares one correlation ID, assigned here so
|
||||
// resolution and discovery are traceable even when execution never
|
||||
// happens.
|
||||
if correlationIDFromCtx(ctx) == "" {
|
||||
ctx = withCorrelationID(ctx, newCorrelationID())
|
||||
}
|
||||
|
||||
// Resolve the utterance text as an entity reference through Nexus. An
|
||||
// ambiguous match must stop and clarify — never guess a mutation target.
|
||||
started := h.now()
|
||||
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
|
||||
if err != nil {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
}
|
||||
// A genuine Nexus dependency failure, not "no such entity" — stop here
|
||||
// and report degradation rather than silently falling through to the
|
||||
// local command executor (ECOSYSTEM-SPEC.md: services degrade
|
||||
@@ -230,19 +529,33 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
||||
map[string]any{"candidates": len(ambiguous)})
|
||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||||
}
|
||||
if entityID == "" {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
||||
map[string]any{"subject": redactSubject(dec.Slots.Text)})
|
||||
return ""
|
||||
}
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
|
||||
map[string]any{"entity_id": entityID})
|
||||
|
||||
// Discover Hexis capabilities for this entity. A resolved entity with a
|
||||
// genuine Hexis failure must not be treated as "no capabilities" and
|
||||
// fall through to unrelated local execution.
|
||||
discovered := h.now()
|
||||
caps, err := h.ecosystem.discoverCapabilities(ctx, entityID)
|
||||
if err != nil {
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
}
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
}
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
||||
map[string]any{"entity_id": entityID, "count": len(caps)})
|
||||
if len(caps) == 0 {
|
||||
return ""
|
||||
}
|
||||
@@ -287,6 +600,8 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
expiry: h.now().Add(confirmTTL),
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
||||
map[string]any{"entity_id": entityID, "capability": matched.Name})
|
||||
return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»."
|
||||
}
|
||||
|
||||
@@ -297,16 +612,23 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
// the correlation ID. It reports command success, never operational recovery
|
||||
// (Praxis observes recovery independently).
|
||||
func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityID, displayName string) string {
|
||||
started := h.now()
|
||||
causationID := correlationIDFromCtx(ctx)
|
||||
correlationID, err := h.ecosystem.executeCapability(ctx, capID, entityID, nil)
|
||||
traced := withCorrelationID(ctx, correlationID)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: hexis execute error (cor=%s): %v", correlationID, err)
|
||||
h.recordEcosystemTrace(traced, "hexis", "execute", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{
|
||||
"entity_id": entityID, "capability": capName, "causation_id": causationID,
|
||||
}))
|
||||
return "не получилось выполнить команду для " + displayName + "."
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "hexis:"+capName, map[string]any{
|
||||
"entity_id": entityID,
|
||||
"entity_name": displayName,
|
||||
"capability": capName,
|
||||
"correlation_id": correlationID,
|
||||
// One record per hop: the second write this used to make said the same
|
||||
// thing under a different key, in a different shape.
|
||||
h.recordEcosystemTrace(traced, "hexis", "execute", traceOK, started, map[string]any{
|
||||
"entity_id": entityID, "entity_name": displayName,
|
||||
"capability": capName, "causation_id": causationID,
|
||||
})
|
||||
return "команда выполнена для " + displayName + "."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
)
|
||||
|
||||
// TestWireEcosystem_HexisToken — a configured Hexis token reaches the wire.
|
||||
//
|
||||
// This is the regression that closes the 2026-08-01 re-vendor. The copy of
|
||||
// github.com/kami/hexis checked into vendor/ used to predate Client.WithToken,
|
||||
// so a configured token could not be sent at all; wireEcosystem refused to wire
|
||||
// Hexis rather than execute unauthenticated. Both halves of that are gone. The
|
||||
// test asserts the outcome the refusal was standing in for: the header goes
|
||||
// out, so nobody has to trust a boot log to know auth is on.
|
||||
func TestWireEcosystem_HexisToken(t *testing.T) {
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := &config.Config{Hexis: &config.HexisConfig{URL: srv.URL, Token: "s3cret"}}
|
||||
w := wireEcosystem(cfg)
|
||||
if w.hexis == nil {
|
||||
t.Fatal("hexis not wired with a token configured")
|
||||
}
|
||||
if _, err := w.discoverCapabilities(context.Background(), "entity-1"); err != nil {
|
||||
t.Fatalf("discoverCapabilities: %v", err)
|
||||
}
|
||||
if want := "Bearer s3cret"; gotAuth != want {
|
||||
t.Errorf("Authorization = %q; want %q", gotAuth, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireEcosystem_HexisNoToken — no token configured still wires, unauthed.
|
||||
// Hexis without auth is a valid deployment on a trusted box, and the re-vendor
|
||||
// must not have turned the token into a requirement.
|
||||
func TestWireEcosystem_HexisNoToken(t *testing.T) {
|
||||
var sawAuth bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sawAuth = r.Header.Get("Authorization") != ""
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := &config.Config{Hexis: &config.HexisConfig{URL: srv.URL}}
|
||||
w := wireEcosystem(cfg)
|
||||
if w.hexis == nil {
|
||||
t.Fatal("hexis not wired without a token")
|
||||
}
|
||||
if _, err := w.discoverCapabilities(context.Background(), "entity-1"); err != nil {
|
||||
t.Fatalf("discoverCapabilities: %v", err)
|
||||
}
|
||||
if sawAuth {
|
||||
t.Error("Authorization header sent with no token configured")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
hexisclient "github.com/kami/hexis/pkg/client"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Phase-5 hardening suite (Vikunja #276). Everything here drives the shared
|
||||
// fake ecosystem (fakeecosystem_test.go) rather than one-off inline handlers,
|
||||
// so the same fault levers — SetFault, SetBody, SetDelay — cover every
|
||||
// service. What is asserted is the degraded-mode contract:
|
||||
//
|
||||
// - services degrade independently: one outage never mutes the others,
|
||||
// - a degraded reply is never silent, never fabricated, never "success",
|
||||
// - contract drift (old shape, unknown fields, garbage) is survivable,
|
||||
// - Maven never acts on an ambiguous target and never chains
|
||||
// Praxis observation into Hexis execution on its own.
|
||||
|
||||
// ecoHandler wires a handler against whichever of the three fakes is given
|
||||
// (pass nil to leave a service unconfigured, which is a different state from
|
||||
// "configured but down").
|
||||
func ecoHandler(t *testing.T, nexus, praxis, hexis *fakeServer) *reactiveHandler {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
clock := newTickingClock(time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), time.Millisecond)
|
||||
w := &ecosystemWiring{}
|
||||
if nexus != nil {
|
||||
w.nexus = newNexusClient(nexus.URL)
|
||||
}
|
||||
if praxis != nil {
|
||||
w.praxis = newPraxisClient(praxis.URL)
|
||||
}
|
||||
if hexis != nil {
|
||||
w.hexis = hexisclient.New(hexis.URL)
|
||||
}
|
||||
return &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
dataStore: st,
|
||||
now: clock.Now,
|
||||
ecosystem: w,
|
||||
}
|
||||
}
|
||||
|
||||
// traces reads the ecosystem trace table. Traces live there and not in facts,
|
||||
// so a bounded reader of facts never fills up with machine-rate rows.
|
||||
func traces(t *testing.T, h *reactiveHandler) []store.EcosystemTrace {
|
||||
t.Helper()
|
||||
out, err := h.dataStore.RecentEcosystemTraces(context.Background(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("read traces: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tracesFor returns the traces recorded for one service+operation.
|
||||
func tracesFor(t *testing.T, h *reactiveHandler, service, op string) []store.EcosystemTrace {
|
||||
t.Helper()
|
||||
var out []store.EcosystemTrace
|
||||
for _, tr := range traces(t, h) {
|
||||
if tr.Service == service && tr.Operation == op {
|
||||
out = append(out, tr)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// restartCaps is a read-only capability. Restarting a service is a mutation,
|
||||
// so the read-only one this suite runs through the happy paths is named for
|
||||
// what it is; the mutating restart lives in the confirmation tests.
|
||||
func restartCaps() string {
|
||||
return fixtureHexisCapabilities(map[string]any{
|
||||
"id": "cap_status", "name": "restart status", "read_only": true,
|
||||
})
|
||||
}
|
||||
|
||||
// TestEcosystem_OutagesLeaveNoSharedFailureState: the two act paths share a
|
||||
// handler, a store and a clock, so what is worth asserting is that a failure
|
||||
// on one leaves nothing behind that degrades the other. Faulting one disjoint
|
||||
// call graph and exercising the other only tests the call graph.
|
||||
func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{
|
||||
"id": "item_1", "title": "disk almost full", "importance": 3.0,
|
||||
}))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, praxis, hexis)
|
||||
|
||||
// A Nexus outage during a Hexis act writes a failure trace, and a shared
|
||||
// store is the one thing the Praxis path could inherit it through.
|
||||
nexus.SetFault(503)
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||
}
|
||||
if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
|
||||
t.Fatal("the failed resolve must be recorded")
|
||||
}
|
||||
|
||||
nexus.SetFault(0)
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("a recorded nexus failure must not degrade the praxis digest, got %q", reply)
|
||||
}
|
||||
if got := tracesFor(t, h, "praxis", "list_attention"); len(got) != 1 || got[0].Status != traceOK {
|
||||
t.Fatalf("the praxis digest must trace its own success, got %+v", got)
|
||||
}
|
||||
|
||||
// And the reverse: a Praxis outage mid-session leaves the Hexis path whole.
|
||||
praxis.SetFault(503)
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
|
||||
t.Fatalf("praxis outage must not serve content, got %q", reply)
|
||||
}
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("a praxis outage must not block the hexis path, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_OneEndpointDownDoesNotMuteTheService: real outages are usually
|
||||
// partial. Attention answering while surface is down must still deliver.
|
||||
func TestEcosystem_OneEndpointDownDoesNotMuteTheService(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{
|
||||
"id": "item_1", "title": "disk almost full", "importance": 3.0,
|
||||
}))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
praxis.SetRouteFault("/api/v1/tools/surface", 503)
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("a downed surface endpoint must not mute the digest, got %q", reply)
|
||||
}
|
||||
if praxis.Count("POST", "/api/v1/tools/surface") == 0 {
|
||||
t.Fatal("expected the surface attempt")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_ResolvedWithoutEntityFailsClosed: the contract violation that
|
||||
// decodes cleanly. Nexus says "resolved" and delivers no entity; treating that
|
||||
// as "no such entity" put the user's verb through to the local executor.
|
||||
func TestEcosystem_ResolvedWithoutEntityFailsClosed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolvedEmpty())
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if reply == "" {
|
||||
t.Fatal("a resolve with no entity must degrade, not fall through to local execution")
|
||||
}
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("a resolve with no entity must not report success, got %q", reply)
|
||||
}
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
t.Fatal("hexis must not be contacted after a contract-violating resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_RejectedCredentialSaysSo: 401 and 403 must not read as an
|
||||
// outage. "Try again" is advice that never works for a misconfigured token.
|
||||
func TestEcosystem_RejectedCredentialSaysSo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, status := range []int{401, 403} {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
nexus.SetFault(status)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !strings.Contains(reply, "токен") {
|
||||
t.Fatalf("http %d must read as a credential problem, got %q", status, reply)
|
||||
}
|
||||
tr := tracesFor(t, h, "nexus", "resolve")
|
||||
if len(tr) != 1 || tr[0].Status != traceRefused || tr[0].HTTPStatus != status {
|
||||
t.Fatalf("http %d must trace as refused with its status, got %+v", status, tr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_MalformedPraxisBodyDegrades: Praxis has the same decode path
|
||||
// Nexus does, and a 200 carrying garbage there is a dependency failure too.
|
||||
func TestEcosystem_MalformedPraxisBodyDegrades(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{
|
||||
"id": "item_1", "title": "disk almost full", "importance": 3.0,
|
||||
}))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
praxis.SetBody(`[{"title":`)
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if reply == "" {
|
||||
t.Fatal("a malformed praxis body must not answer with silence")
|
||||
}
|
||||
if strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("a malformed body must not produce content, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_MalformedNexusResponseFailsClosed: a 200 carrying garbage is a
|
||||
// dependency failure, not "no such entity". It must stop before Hexis.
|
||||
func TestEcosystem_MalformedNexusResponseFailsClosed(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)
|
||||
|
||||
nexus.SetBody(`{"status":"resolved","entity":`)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if reply == "" || strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("malformed nexus body must degrade, got %q", reply)
|
||||
}
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
t.Fatal("hexis must not be contacted after a malformed nexus response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_UnknownContractFieldsTolerated: a newer Nexus adding fields
|
||||
// must not break an older Maven. Same for the older flat resolve shape.
|
||||
func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for name, body := range map[string]string{
|
||||
"future": fixtureNexusResolvedFuture("ent_muzick", "Muzick indexer", "service"),
|
||||
"flat": fixtureNexusResolvedFlat("ent_muzick", "Muzick indexer", "service"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
nexus := newFakeNexus(t, body)
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_CancelledContextDegrades: a caller hanging up (turn abandoned,
|
||||
// deadline hit) must surface as degradation, never as a fabricated result.
|
||||
func TestEcosystem_CancelledContextDegrades(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
nexus.SetDelay(2 * time.Second)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if reply == "" || strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("cancelled resolve must degrade, got %q", reply)
|
||||
}
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
t.Fatal("hexis must not be contacted after a cancelled resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_ExecutionFailureIsNotSuccess: Hexis answering 200 with
|
||||
// status=failed is a partial failure — the call worked, the command did not.
|
||||
// Maven must report it as a failure and must not write a success trace.
|
||||
func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecutionFailed("exec_1", "unit not found"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("failed execution must not read as success, got %q", reply)
|
||||
}
|
||||
if reply == "" {
|
||||
t.Fatal("failed execution must say something")
|
||||
}
|
||||
for _, tr := range tracesFor(t, h, "hexis", "execute") {
|
||||
if tr.Status == traceOK {
|
||||
t.Fatalf("failed execution must not write a success trace: %+v", tr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_SuccessfulActionWritesATrace is the positive half the failure
|
||||
// assertions above depend on: without it, "no success trace" passes with the
|
||||
// trace writer deleted. It was, for a while — both writers used a fact kind the
|
||||
// store's CHECK constraint rejects and the error was discarded.
|
||||
func TestEcosystem_SuccessfulActionWritesATrace(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)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
exec := tracesFor(t, h, "hexis", "execute")
|
||||
if len(exec) != 1 || exec[0].Status != traceOK {
|
||||
t.Fatalf("a successful execution must leave exactly one ok trace, got %+v", exec)
|
||||
}
|
||||
if exec[0].CorrelationID == "" {
|
||||
t.Error("a trace with no correlation id cannot be stitched to anything")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_TracesStayOutOfFacts: traces are written at machine rate and
|
||||
// facts at human rate. One act turn used to write four fact rows, which pushed
|
||||
// his facts out of every bounded reader (the habit profile's window, memeval's
|
||||
// prompt, /dash, /history).
|
||||
func TestEcosystem_TracesStayOutOfFacts(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)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
if len(traces(t, h)) == 0 {
|
||||
t.Fatal("setup: expected traces")
|
||||
}
|
||||
facts, err := h.dataStore.RecentFacts(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("read facts: %v", err)
|
||||
}
|
||||
if len(facts) != 0 {
|
||||
t.Fatalf("an ecosystem act must write no facts at all, got %+v", facts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_AmbiguousTargetBlocksExecution: ambiguity blocks mutation, and
|
||||
// the clarification must name the candidates rather than pick one.
|
||||
func TestEcosystem_AmbiguousTargetBlocksExecution(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusAmbiguous(
|
||||
map[string]string{"entity_id": "ent_a", "display_name": "Muzick indexer"},
|
||||
map[string]string{"entity_id": "ent_b", "display_name": "Muzick web"},
|
||||
))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick"))
|
||||
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Muzick web") {
|
||||
t.Fatalf("ambiguous resolve must list candidates, got %q", reply)
|
||||
}
|
||||
if hexis.Count("POST", "/api/v1/execute") != 0 {
|
||||
t.Fatal("ambiguous target must never execute")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_NoAutonomousPraxisToHexis: reading the attention digest is an
|
||||
// observation. Maven must never turn an observed problem into a Hexis command
|
||||
// by herself — she is not autonomous.
|
||||
func TestEcosystem_NoAutonomousPraxisToHexis(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
|
||||
map[string]any{"id": "item_1", "title": "muzick indexer is down", "importance": 4.0, "rule": "service_down"},
|
||||
))
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, praxis, hexis)
|
||||
|
||||
_ = h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
t.Fatal("attention digest must not contact hexis on its own")
|
||||
}
|
||||
if nexus.Count("", "/api/v1/resolve") != 0 {
|
||||
t.Fatal("attention digest must not resolve targets for autonomous action")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_MutatingCapabilityWaitsForConfirmation: a non-read-only
|
||||
// capability parks for an explicit spoken confirm bound to capability+target.
|
||||
func TestEcosystem_MutatingCapabilityWaitsForConfirmation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("restart"))
|
||||
if !strings.Contains(reply, "restart") || !strings.Contains(reply, "да") {
|
||||
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
|
||||
}
|
||||
if hexis.Count("POST", "/api/v1/execute") != 0 {
|
||||
t.Fatal("mutating capability must not execute before confirmation")
|
||||
}
|
||||
h.mu.Lock()
|
||||
pending := h.pendingHexis
|
||||
h.mu.Unlock()
|
||||
if pending == nil || pending.capabilityID != "cap_restart" || pending.entityID != "ent_muzick" {
|
||||
t.Fatalf("confirmation must be bound to capability+target, got %+v", pending)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_SurfaceFailureStillDelivers: surfacing is bookkeeping. If the
|
||||
// surface call fails the digest must still be spoken — a partial failure
|
||||
// downgrades bookkeeping, not the answer.
|
||||
func TestEcosystem_SurfaceFailureStillDelivers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
|
||||
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
|
||||
))
|
||||
praxis.SetRouteFault("/api/v1/tools/surface", 500)
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("failed surface must not swallow the digest, got %q", reply)
|
||||
}
|
||||
if praxis.Count("POST", "/api/v1/tools/surface") == 0 {
|
||||
t.Fatal("expected the surface attempt")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_TotalOutageSaysSoForEveryPath: with all three down, every
|
||||
// entry point degrades explicitly instead of returning empty or inventing.
|
||||
func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
for _, fs := range []*fakeServer{nexus, praxis, hexis} {
|
||||
fs.SetFault(503)
|
||||
}
|
||||
h := ecoHandler(t, nexus, praxis, hexis)
|
||||
|
||||
for name, reply := range map[string]string{
|
||||
"hexis act": h.handleHexisAct(ctx, actDec("muzick indexer")),
|
||||
"attention": h.handlePraxisAct(ctx, praxisActDec("list_attention")),
|
||||
"changes": h.handlePraxisAct(ctx, praxisActDec("list_changes")),
|
||||
"acknowledge": h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1")),
|
||||
} {
|
||||
if reply == "" {
|
||||
t.Errorf("%s: total outage must not answer with silence", name)
|
||||
}
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
t.Errorf("%s: total outage must not claim success: %q", name, reply)
|
||||
}
|
||||
}
|
||||
for _, tr := range traces(t, h) {
|
||||
if tr.Status == traceOK {
|
||||
t.Fatalf("a total outage must not leave success traces behind: %+v", tr)
|
||||
}
|
||||
}
|
||||
if len(tracesFor(t, h, "praxis", "acknowledge")) == 0 {
|
||||
t.Fatal("the acknowledge arm must reach praxis and record the refusal")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_RecoveryAfterOutageNeedsNoRestart: once the dependency comes
|
||||
// back the very next turn works — no cached failure state, no restart.
|
||||
func TestEcosystem_RecoveryAfterOutageNeedsNoRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
|
||||
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
|
||||
))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
praxis.SetFault(503)
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
|
||||
t.Fatalf("outage must not serve content, got %q", reply)
|
||||
}
|
||||
praxis.SetFault(0)
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("recovery must work on the next turn, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,13 @@ func praxisActDec(fn string) router.Decision {
|
||||
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: fn, HasFn: true}}
|
||||
}
|
||||
|
||||
// praxisItemDec is praxisActDec for the lifecycle verbs, which need an item id
|
||||
// in the value slot. Without one they answer "which item?" and never reach
|
||||
// Praxis at all, which makes them useless for testing a Praxis outage.
|
||||
func praxisItemDec(fn, itemID string) router.Decision {
|
||||
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: fn, HasFn: true, Value: itemID}}
|
||||
}
|
||||
|
||||
func newPraxisTestHandler(t *testing.T, praxis *fakeServer) *reactiveHandler {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
|
||||
@@ -59,8 +59,11 @@ func newHexisTestHandler(t *testing.T, resolveBody string, caps string) (*reacti
|
||||
}, executed
|
||||
}
|
||||
|
||||
func actDec(text string) router.Decision {
|
||||
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Text: text, Fn: "restart", HasFn: true}}
|
||||
// actDec builds an act decision about subject. The verb is always "restart":
|
||||
// the argument is the utterance the entity is resolved from, never the verb,
|
||||
// so actDec("restart") reads as a verb and is not one.
|
||||
func actDec(subject string) router.Decision {
|
||||
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Text: subject, Fn: "restart", HasFn: true}}
|
||||
}
|
||||
|
||||
func TestHexisMutatingRequiresConfirm(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Versioning, authentication and tracing of ecosystem calls (Vikunja #273).
|
||||
|
||||
func findTrace(t *testing.T, h *reactiveHandler, service, op string) *store.EcosystemTrace {
|
||||
t.Helper()
|
||||
for _, tr := range traces(t, h) {
|
||||
if tr.Service == service && tr.Operation == op {
|
||||
found := tr
|
||||
return &found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestEcosystemHeaders_VersionRequesterAndAuth: every outgoing request carries
|
||||
// the contract version, the requester, and the bearer token when configured.
|
||||
func TestEcosystemHeaders_VersionRequesterAndAuth(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
h.ecosystem.nexus = newNexusClient(nexus.URL).withToken("nexus-secret")
|
||||
h.ecosystem.praxis = newPraxisClient(praxis.URL).withToken("praxis-secret")
|
||||
|
||||
_, _, _, err := h.ecosystem.resolveEntityReference(ctx, "muzick indexer", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
// A bare client call carries whatever the caller assigned. Entry points
|
||||
// assign the ID, the header layer only reads it, so mirror an action here.
|
||||
if _, err := h.ecosystem.praxis.ListAttention(withCorrelationID(ctx, newCorrelationID()), 5); err != nil {
|
||||
t.Fatalf("attention: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
fs *fakeServer
|
||||
versionHeader string
|
||||
token string
|
||||
}{
|
||||
{nexus, "X-Nexus-Version", "nexus-secret"},
|
||||
{praxis, "X-Praxis-Version", "praxis-secret"},
|
||||
} {
|
||||
reqs := tc.fs.Requests()
|
||||
if len(reqs) == 0 {
|
||||
t.Fatalf("%s: no request captured", tc.versionHeader)
|
||||
}
|
||||
r := reqs[0]
|
||||
if got := r.Header.Get(tc.versionHeader); got != ecosystemAPIVersion {
|
||||
t.Errorf("%s = %q, want %q", tc.versionHeader, got, ecosystemAPIVersion)
|
||||
}
|
||||
if got := r.Header.Get("X-Requested-By"); got != mavenRequester {
|
||||
t.Errorf("X-Requested-By = %q, want %q", got, mavenRequester)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer "+tc.token {
|
||||
t.Errorf("Authorization = %q, want bearer %q", got, tc.token)
|
||||
}
|
||||
if r.Header.Get("X-Correlation-ID") == "" {
|
||||
t.Errorf("%s: missing correlation ID", tc.versionHeader)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemHeaders_NoTokenSendsNoAuth: an unconfigured token means the
|
||||
// transport is trusted, not that a bogus header is sent.
|
||||
func TestEcosystemHeaders_NoTokenSendsNoAuth(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
h := ecoHandler(t, nexus, nil, nil)
|
||||
|
||||
if _, _, _, err := h.ecosystem.resolveEntityReference(ctx, "muzick indexer", nil); err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if got := nexus.Requests()[0].Header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("unauthenticated client must send no Authorization header, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemError_ClassifiesRefusals: callers must be able to tell a
|
||||
// rejected credential from a version refusal from an unreachable service
|
||||
// without matching on message text.
|
||||
func TestEcosystemError_ClassifiesRefusals(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
check func(*ecosystemError) bool
|
||||
wantCls string
|
||||
}{
|
||||
{"unauthorized", 401, (*ecosystemError).Unauthorized, "unauthorized"},
|
||||
{"forbidden", 403, (*ecosystemError).Unauthorized, "unauthorized"},
|
||||
{"contract", 426, (*ecosystemError).ContractMismatch, "contract_mismatch"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_x", "X", "service"))
|
||||
nexus.SetFault(tc.status)
|
||||
c := newNexusClient(nexus.URL)
|
||||
_, err := c.Resolve(ctx, "x", nil)
|
||||
ee, ok := err.(*ecosystemError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *ecosystemError, got %T (%v)", err, err)
|
||||
}
|
||||
if ee.Service != "nexus" || ee.Status != tc.status {
|
||||
t.Fatalf("unexpected typed error %+v", ee)
|
||||
}
|
||||
if !tc.check(ee) {
|
||||
t.Fatalf("%s not classified: %+v", tc.name, ee)
|
||||
}
|
||||
if got := traceErrorFields(err)["class"]; got != tc.wantCls {
|
||||
t.Fatalf("trace class = %v, want %s", got, tc.wantCls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEcosystemError_UnreachableHasNoStatus(t *testing.T) {
|
||||
c := newNexusClient("http://127.0.0.1:1")
|
||||
_, err := c.Resolve(context.Background(), "x", nil)
|
||||
ee, ok := err.(*ecosystemError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *ecosystemError, got %T", err)
|
||||
}
|
||||
if !ee.Unreachable() || ee.Unauthorized() || ee.ContractMismatch() {
|
||||
t.Fatalf("a refused connection must classify as unreachable only: %+v", ee)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_SuccessfulActionTracesEveryHop: resolution, discovery and
|
||||
// execution each leave a record sharing one correlation chain, with timing and
|
||||
// status, and execution carries the causation link back to the resolve.
|
||||
func TestEcosystemTrace_SuccessfulActionTracesEveryHop(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)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
|
||||
var chain string
|
||||
for _, want := range [][2]string{{"nexus", "resolve"}, {"hexis", "capabilities"}, {"hexis", "execute"}} {
|
||||
d := findTrace(t, h, want[0], want[1])
|
||||
if d == nil {
|
||||
t.Fatalf("missing trace for %s %s, got %+v", want[0], want[1], traces(t, h))
|
||||
}
|
||||
if d.Status != traceOK {
|
||||
t.Errorf("%s %s status = %v, want ok", want[0], want[1], d.Status)
|
||||
}
|
||||
if d.CorrelationID == "" {
|
||||
t.Errorf("%s %s trace has no correlation id", want[0], want[1])
|
||||
}
|
||||
if want[1] != "execute" {
|
||||
if chain == "" {
|
||||
chain = d.CorrelationID
|
||||
} else if d.CorrelationID != chain {
|
||||
t.Errorf("%s %s left the correlation chain: %s != %s", want[0], want[1], d.CorrelationID, chain)
|
||||
}
|
||||
}
|
||||
}
|
||||
exec := findTrace(t, h, "hexis", "execute")
|
||||
if exec.CausationID == "" {
|
||||
t.Error("execute trace must carry the causation id of the turn that caused it")
|
||||
}
|
||||
if exec.CorrelationID == exec.CausationID {
|
||||
t.Error("execute correlation and causation must be distinguishable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_OneCorrelationIDPerPraxisAction: a digest calls attention
|
||||
// once and surface once per item. All of it is one turn, so the far side must
|
||||
// see one ID and not N+1 unrelated ones.
|
||||
func TestEcosystemTrace_OneCorrelationIDPerPraxisAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
|
||||
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
|
||||
map[string]any{"id": "item_2", "title": "backup is stale", "importance": 2.0},
|
||||
))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("setup: expected the digest, got %q", reply)
|
||||
}
|
||||
|
||||
reqs := praxis.Requests()
|
||||
if len(reqs) < 3 {
|
||||
t.Fatalf("expected attention plus one surface per item, got %d requests", len(reqs))
|
||||
}
|
||||
first := reqs[0].Header.Get("X-Correlation-ID")
|
||||
if first == "" {
|
||||
t.Fatal("every ecosystem request must carry a correlation id")
|
||||
}
|
||||
for _, r := range reqs {
|
||||
if got := r.Header.Get("X-Correlation-ID"); got != first {
|
||||
t.Fatalf("%s %s carried %q, want the action's id %q", r.Method, r.Path, got, first)
|
||||
}
|
||||
}
|
||||
tr := findTrace(t, h, "praxis", "list_attention")
|
||||
if tr == nil || tr.CorrelationID != first {
|
||||
t.Fatalf("the trace must carry the id that was actually sent, got %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_FailuresAreTracedToo: the whole point of the change —
|
||||
// a failed hop is exactly the one worth having recorded.
|
||||
func TestEcosystemTrace_FailuresAreTracedToo(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)
|
||||
nexus.SetFault(401)
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d == nil {
|
||||
t.Fatal("a failed resolve must still be traced")
|
||||
}
|
||||
if d.Status != traceRefused {
|
||||
t.Errorf("status = %v, want refused: the far side answered", d.Status)
|
||||
}
|
||||
if d.Fields["class"] != "unauthorized" {
|
||||
t.Errorf("class = %v, want unauthorized", d.Fields["class"])
|
||||
}
|
||||
if d.HTTPStatus != 401 {
|
||||
t.Errorf("http_status = %v, want 401", d.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_UnreachableIsNotRefused: never got an answer and answered
|
||||
// with a refusal are different failures, and the trace must say which.
|
||||
func TestEcosystemTrace_UnreachableIsNotRefused(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h := ecoHandler(t, nil, nil, nil)
|
||||
h.ecosystem.nexus = newNexusClient("http://127.0.0.1:1")
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d == nil {
|
||||
t.Fatal("an unreachable resolve must still be traced")
|
||||
}
|
||||
if d.Status != traceFailed {
|
||||
t.Errorf("status = %v, want failed", d.Status)
|
||||
}
|
||||
if d.Fields["class"] != "unreachable" {
|
||||
t.Errorf("class = %v, want unreachable", d.Fields["class"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_RedactsTheUtterance: traces are diagnostics, his words
|
||||
// are not. The subject must never be persisted verbatim.
|
||||
func TestEcosystemTrace_RedactsTheUtterance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusNotFound())
|
||||
h := ecoHandler(t, nexus, nil, nil)
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("перезапусти кофемашину"))
|
||||
|
||||
recorded := traces(t, h)
|
||||
if len(recorded) == 0 {
|
||||
t.Fatal("expected a not_found resolve trace")
|
||||
}
|
||||
for _, tr := range recorded {
|
||||
for k, v := range tr.Fields {
|
||||
if s, ok := v.(string); ok && strings.Contains(s, "кофемашину") {
|
||||
t.Fatalf("trace leaked the utterance in %s: %q", k, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d.Status != traceNotFound {
|
||||
t.Errorf("status = %v, want not_found", d.Status)
|
||||
}
|
||||
if d.Fields["subject"] != redactSubject("перезапусти кофемашину") {
|
||||
t.Errorf("subject = %v, want a redacted length", d.Fields["subject"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded: the two moments
|
||||
// where Maven deliberately does not act still leave a trail.
|
||||
func TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ambig := newFakeNexus(t, fixtureNexusAmbiguous(
|
||||
map[string]string{"entity_id": "ent_a", "display_name": "Muzick indexer"},
|
||||
map[string]string{"entity_id": "ent_b", "display_name": "Muzick web"},
|
||||
))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, ambig, nil, hexis)
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick"))
|
||||
if d := findTrace(t, h, "nexus", "resolve"); d == nil || d.Status != traceAmbig {
|
||||
t.Fatalf("ambiguous resolve must be traced as such, got %+v", d)
|
||||
}
|
||||
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
mutating := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
|
||||
h2 := ecoHandler(t, nexus, nil, newFakeHexis(t, mutating, fixtureHexisExecuted("exec_1", "succeeded")))
|
||||
_ = h2.handleHexisAct(ctx, actDec("restart"))
|
||||
d := findTrace(t, h2, "hexis", "confirmation")
|
||||
if d == nil || d.Status != tracePending {
|
||||
t.Fatalf("a parked confirmation must be traced, got %+v", d)
|
||||
}
|
||||
// The confirmation hop is measured from the top of the action, not from
|
||||
// the instant it is recorded, which was always zero.
|
||||
if d.DurationMs == 0 {
|
||||
t.Error("the confirmation trace must report the time the action took to get there")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Entity-ref propagation, Maven side (Vikunja #272): the canonical Nexus
|
||||
// entity_id must reach Praxis as a query scope rather than being resolved and
|
||||
// then thrown away, and the enrichment that produces those ids must degrade
|
||||
// visibly instead of silently.
|
||||
|
||||
func entityAttentionDec(subject string) router.Decision {
|
||||
return router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "entity_attention", HasFn: true, Value: subject},
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_ScopesPraxisByCanonicalID: the resolved id must travel
|
||||
// to Praxis in the request, not be used for client-side filtering.
|
||||
func TestEntityAttention_ScopesPraxisByCanonicalID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionScoped("ent_muzick",
|
||||
map[string]any{"id": "item_1", "title": "indexer queue is backing up", "importance": 3.0},
|
||||
))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
if !strings.Contains(reply, "indexer queue is backing up") {
|
||||
t.Fatalf("expected the scoped item in the reply, got %q", reply)
|
||||
}
|
||||
|
||||
var scoped bool
|
||||
for _, r := range praxis.Requests() {
|
||||
if r.Method == "GET" && strings.HasPrefix(r.Path, "/api/v1/tools/attention") &&
|
||||
strings.Contains(r.Query, "entity_id=ent_muzick") {
|
||||
scoped = true
|
||||
}
|
||||
}
|
||||
if !scoped {
|
||||
t.Fatalf("expected attention scoped by entity_id, got requests %+v", praxis.Requests())
|
||||
}
|
||||
if praxis.Count("POST", "/api/v1/tools/surface") == 0 {
|
||||
t.Error("a spoken scoped item must be surfaced, like the unscoped digest")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_FoldsInLocalFactsForSameEntity: facts the enrichment
|
||||
// worker already tagged with the same canonical id join the same answer.
|
||||
func TestEntityAttention_FoldsInLocalFactsForSameEntity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
id, err := h.dataStore.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv,
|
||||
"descaled", "the espresso machine", "descaled in june", "infer:pref", 0.8, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
if err := h.dataStore.ResolveFactEntity(ctx, id, "ent_espresso", store.ResolutionResolved); err != nil {
|
||||
t.Fatalf("ResolveFactEntity: %v", err)
|
||||
}
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"))
|
||||
if !strings.Contains(reply, "descaled in june") {
|
||||
t.Fatalf("expected entity-scoped local facts in the reply, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_UnscopedPraxisResponseIsRefused: a Praxis old enough to
|
||||
// ignore the entity_id parameter answers the scoped question with the whole
|
||||
// unscoped list. Relabelling those items "по «X»" is the same fabrication the
|
||||
// canonical ref exists to prevent, arriving through a different door.
|
||||
func TestEntityAttention_UnscopedPraxisResponseIsRefused(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
|
||||
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
|
||||
))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
if strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("an unscoped response must not be read back as entity-scoped, got %q", reply)
|
||||
}
|
||||
if reply == "" {
|
||||
t.Fatal("refusing the answer must still say something")
|
||||
}
|
||||
if praxis.Count("POST", "/api/v1/tools/surface") != 0 {
|
||||
t.Error("items that were never spoken must not be surfaced")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_ForeignItemsAreDropped: items tagged with another entity
|
||||
// are dropped rather than spoken under this entity's name.
|
||||
func TestEntityAttention_ForeignItemsAreDropped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
mixed := []map[string]any{
|
||||
{"id": "item_1", "title": "indexer queue is backing up", "importance": 3.0, "entity_id": "ent_muzick"},
|
||||
{"id": "item_2", "title": "the kettle is descaling", "importance": 1.0, "entity_id": "ent_kettle"},
|
||||
}
|
||||
praxis := newFakePraxis(t, mustJSON(mixed))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
if !strings.Contains(reply, "indexer queue is backing up") {
|
||||
t.Fatalf("the matching item must be spoken, got %q", reply)
|
||||
}
|
||||
if strings.Contains(reply, "kettle") {
|
||||
t.Fatalf("another entity's item must not be spoken here, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_TruncationIsNamed: reading three of many remembered
|
||||
// facts must not be presented as everything she knows.
|
||||
func TestEntityAttention_TruncationIsNamed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
id, err := h.dataStore.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv,
|
||||
"note", "the espresso machine", "факт "+string(rune('а'+i)), "infer:pref", 0.8, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
if err := h.dataStore.ResolveFactEntity(ctx, id, "ent_espresso", store.ResolutionResolved); err != nil {
|
||||
t.Fatalf("ResolveFactEntity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"))
|
||||
if !strings.Contains(reply, "и это не всё") {
|
||||
t.Fatalf("a truncated recall must say it is truncated, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_AmbiguousAsksInsteadOfGuessing.
|
||||
func TestEntityAttention_AmbiguousAsksInsteadOfGuessing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusAmbiguous(
|
||||
map[string]string{"entity_id": "ent_a", "display_name": "Muzick indexer"},
|
||||
map[string]string{"entity_id": "ent_b", "display_name": "Muzick web"},
|
||||
))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick"))
|
||||
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Muzick web") {
|
||||
t.Fatalf("ambiguous subject must ask, got %q", reply)
|
||||
}
|
||||
if praxis.Count("GET", "/api/v1/tools/attention") != 0 {
|
||||
t.Fatal("an ambiguous subject must not be queried against praxis")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_MissingAndDegradedAreDistinct: "no such entity" and
|
||||
// "Nexus is down" must not produce the same answer.
|
||||
func TestEntityAttention_MissingAndDegradedAreDistinct(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusNotFound())
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
missing := h.handlePraxisAct(ctx, entityAttentionDec("нечто"))
|
||||
if missing == "" {
|
||||
t.Fatal("an unknown entity must still get an answer")
|
||||
}
|
||||
|
||||
nexus.SetFault(503)
|
||||
degraded := h.handlePraxisAct(ctx, entityAttentionDec("нечто"))
|
||||
if degraded == missing {
|
||||
t.Fatalf("outage and unknown-entity must not read the same: %q", degraded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_DelayedNexusDegradesNotHangs: a slow Nexus past the
|
||||
// caller's deadline degrades and never queries Praxis with an empty scope.
|
||||
func TestEntityAttention_DelayedNexusDegradesNotHangs(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
nexus.SetDelay(2 * time.Second)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
if reply == "" {
|
||||
t.Fatal("a delayed resolve must still answer")
|
||||
}
|
||||
if praxis.Count("GET", "/api/v1/tools/attention") != 0 {
|
||||
t.Fatal("praxis must not be queried without a resolved scope")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_WithoutNexusSaysSo: no Nexus means no canonical ref, so
|
||||
// the scoped query is refused rather than answered about something else.
|
||||
func TestEntityAttention_WithoutNexusSaysSo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
|
||||
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
|
||||
))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
if strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("without nexus, items must not be passed off as entity-scoped, got %q", reply)
|
||||
}
|
||||
if praxis.Count("GET", "/api/v1/tools/attention") != 0 {
|
||||
t.Fatal("no canonical ref means no scoped query at all")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichmentBackoff_HoldsAndReleases: repeated Nexus failures back the
|
||||
// fact off instead of hammering, and the fact is retried once the window
|
||||
// elapses. Nothing is ever given up on.
|
||||
func TestEnrichmentBackoff_HoldsAndReleases(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
|
||||
st := newTestStore(t)
|
||||
if _, err := st.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv, "likes",
|
||||
"the espresso machine", `"true"`, "infer:pref", 0.8, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
|
||||
clock := newFakeClock(time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC))
|
||||
w := newFactEnrichmentWorker(st, stubEcosystem(nexus.URL, ""), time.Hour)
|
||||
w.now = clock.Now
|
||||
|
||||
nexus.SetFault(503)
|
||||
w.tick(ctx)
|
||||
failedCalls := nexus.Count("POST", "/api/v1/resolve")
|
||||
if failedCalls != 1 {
|
||||
t.Fatalf("expected one resolve attempt, got %d", failedCalls)
|
||||
}
|
||||
|
||||
// Immediately after a failure the fact is in backoff: no second call.
|
||||
w.tick(ctx)
|
||||
if nexus.Count("POST", "/api/v1/resolve") != failedCalls {
|
||||
t.Fatal("a fact in backoff must not be retried on the very next tick")
|
||||
}
|
||||
if s := w.status(ctx); s.Pending != 1 || s.InBackoff != 1 || s.MaxAttempts != 1 {
|
||||
t.Fatalf("degradation must be reported, got %+v", s)
|
||||
}
|
||||
|
||||
// Once the window elapses and Nexus recovers, the fact resolves.
|
||||
clock.Advance(2 * time.Minute)
|
||||
nexus.SetFault(0)
|
||||
w.tick(ctx)
|
||||
facts, err := st.FactsByEntity(ctx, "ent_espresso", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("FactsByEntity: %v", err)
|
||||
}
|
||||
if len(facts) != 1 {
|
||||
t.Fatalf("expected the fact resolved after recovery, got %+v", facts)
|
||||
}
|
||||
if s := w.status(ctx); s.Pending != 0 || s.MaxAttempts != 0 {
|
||||
t.Fatalf("recovery must clear the degradation report, got %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichmentBackoff_GrowsAndIsCapped(t *testing.T) {
|
||||
if enrichmentBackoff(1) != time.Minute {
|
||||
t.Fatalf("first retry should be a minute, got %v", enrichmentBackoff(1))
|
||||
}
|
||||
if enrichmentBackoff(3) != 4*time.Minute {
|
||||
t.Fatalf("third retry should be four minutes, got %v", enrichmentBackoff(3))
|
||||
}
|
||||
if enrichmentBackoff(50) != time.Hour {
|
||||
t.Fatalf("backoff must cap at an hour, got %v", enrichmentBackoff(50))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichment_BackedOffFactsDoNotStallTheQueue: the pending queue is ordered
|
||||
// by id, so the oldest facts are pulled first whether or not they are eligible.
|
||||
// A batch of facts in backoff at the head must not hold every slot and stop
|
||||
// enrichment for everything younger.
|
||||
func TestEnrichment_BackedOffFactsDoNotStallTheQueue(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
total := 5
|
||||
for i := 0; i < total; i++ {
|
||||
if _, err := st.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv, "likes",
|
||||
"subject-"+string(rune('a'+i)), `"true"`, "infer:pref", 0.8, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_x", "X", "service"))
|
||||
clock := newFakeClock(time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC))
|
||||
w := newFactEnrichmentWorker(st, stubEcosystem(nexus.URL, ""), time.Hour)
|
||||
w.now = clock.Now
|
||||
// A batch smaller than the queue, so with no scan the last fact never
|
||||
// reaches the head while the first ones are backed off.
|
||||
w.batch = total - 1
|
||||
|
||||
nexus.SetFault(503)
|
||||
w.tick(ctx)
|
||||
if got := nexus.Count("POST", "/api/v1/resolve"); got != total-1 {
|
||||
t.Fatalf("expected the first batch attempted, got %d calls", got)
|
||||
}
|
||||
|
||||
// Second tick with Nexus healthy: the backed-off head must be skipped and
|
||||
// the fact behind it resolved, not the same batch pulled and dropped.
|
||||
nexus.SetFault(0)
|
||||
w.tick(ctx)
|
||||
facts, err := st.FactsByEntity(ctx, "ent_x", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("FactsByEntity: %v", err)
|
||||
}
|
||||
if len(facts) == 0 {
|
||||
t.Fatal("a due fact behind a backed-off batch must still be resolved")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichment_StoreWriteFailureBacksOffToo: the one failure mode where the
|
||||
// resolve worked and the write did not must be paced like any other, not
|
||||
// retried at full rate forever.
|
||||
func TestEnrichment_StoreWriteFailureBacksOffToo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
|
||||
st := newTestStore(t)
|
||||
if _, err := st.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv, "likes",
|
||||
"the espresso machine", `"true"`, "infer:pref", 0.8, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
pending, err := st.PendingFactResolutions(ctx, 10)
|
||||
if err != nil || len(pending) != 1 {
|
||||
t.Fatalf("setup: pending = %+v, %v", pending, err)
|
||||
}
|
||||
|
||||
clock := newFakeClock(time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC))
|
||||
w := newFactEnrichmentWorker(st, stubEcosystem(nexus.URL, ""), time.Hour)
|
||||
w.now = clock.Now
|
||||
|
||||
// Closing the store makes the resolution write fail while the Nexus call
|
||||
// still succeeds — the split this path gets wrong.
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("close store: %v", err)
|
||||
}
|
||||
if w.resolveOne(ctx, pending[0]) {
|
||||
t.Fatal("a failed store write must not report success")
|
||||
}
|
||||
if w.due(pending[0].ID) {
|
||||
t.Fatal("a failed store write must back the fact off like a failed resolve")
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -24,10 +25,88 @@ type factEnrichmentWorker struct {
|
||||
eco *ecosystemWiring
|
||||
interval time.Duration
|
||||
batch int // facts resolved per tick; keeps a single slow tick bounded
|
||||
now func() time.Time
|
||||
|
||||
// Retry state for facts whose resolution failed transiently. Kept in
|
||||
// memory rather than in the DB: a restart legitimately retries
|
||||
// everything, and the backoff exists to spare a struggling Nexus, not
|
||||
// to be durable. A fact is never given up on — degraded means slower,
|
||||
// not dropped.
|
||||
mu sync.Mutex
|
||||
attempt map[int64]int // fact id → consecutive failures
|
||||
nextTry map[int64]time.Time // fact id → earliest retry
|
||||
}
|
||||
|
||||
// enrichmentScanLimit bounds how deep a single tick (or status report) walks
|
||||
// the pending queue looking for facts whose backoff has elapsed. The queue is
|
||||
// ordered by id, so without a scan the oldest facts hold every batch slot
|
||||
// whether or not they are eligible, and one permanently failing fact stalls
|
||||
// every younger one behind it.
|
||||
const enrichmentScanLimit = 1000
|
||||
|
||||
// enrichmentBackoff is the wait before retrying a fact after n consecutive
|
||||
// failures, capped so a long Nexus outage still retries about hourly.
|
||||
func enrichmentBackoff(n int) time.Duration {
|
||||
d := time.Minute
|
||||
for i := 1; i < n && d < time.Hour; i++ {
|
||||
d *= 2
|
||||
}
|
||||
if d > time.Hour {
|
||||
d = time.Hour
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func newFactEnrichmentWorker(st *store.Store, eco *ecosystemWiring, interval time.Duration) *factEnrichmentWorker {
|
||||
return &factEnrichmentWorker{store: st, eco: eco, interval: interval, batch: 20}
|
||||
return &factEnrichmentWorker{
|
||||
store: st,
|
||||
eco: eco,
|
||||
interval: interval,
|
||||
batch: 20,
|
||||
now: time.Now,
|
||||
attempt: map[int64]int{},
|
||||
nextTry: map[int64]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
// enrichmentStatus is what the worker reports about its own health: how many
|
||||
// facts are waiting, how many of those are currently in backoff, and the worst
|
||||
// retry count among them. Degradation is reported, never hidden — a Nexus that
|
||||
// has been down all day must be visible as a backlog, not as facts that
|
||||
// silently never got tagged.
|
||||
//
|
||||
// All three numbers describe the same set of rows, the first
|
||||
// enrichmentScanLimit pending facts. Counting Pending over a thousand rows
|
||||
// while counting InBackoff over the twenty that reached the head of a batch
|
||||
// described two different populations under one struct.
|
||||
type enrichmentStatus struct {
|
||||
Pending int
|
||||
InBackoff int
|
||||
MaxAttempts int
|
||||
Scanned int // rows the other three counts were taken over
|
||||
}
|
||||
|
||||
func (w *factEnrichmentWorker) status(ctx context.Context) enrichmentStatus {
|
||||
var st enrichmentStatus
|
||||
pending, err := w.store.PendingFactResolutions(ctx, enrichmentScanLimit)
|
||||
if err != nil {
|
||||
log.Printf("factenrichment: status: %v", err)
|
||||
return st
|
||||
}
|
||||
st.Pending = len(pending)
|
||||
st.Scanned = len(pending)
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
now := w.now()
|
||||
for _, f := range pending {
|
||||
if next, ok := w.nextTry[f.ID]; ok && now.Before(next) {
|
||||
st.InBackoff++
|
||||
}
|
||||
if n := w.attempt[f.ID]; n > st.MaxAttempts {
|
||||
st.MaxAttempts = n
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func (w *factEnrichmentWorker) run(ctx context.Context) {
|
||||
@@ -52,22 +131,86 @@ func (w *factEnrichmentWorker) run(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (w *factEnrichmentWorker) tick(ctx context.Context) {
|
||||
pending, err := w.store.PendingFactResolutions(ctx, w.batch)
|
||||
// Scan past the facts that are still in backoff instead of letting them
|
||||
// occupy the batch. The queue is ordered by id, so the oldest facts are
|
||||
// pulled first whether or not they are eligible: twenty facts Nexus keeps
|
||||
// rejecting would otherwise hold every slot forever and enrichment would
|
||||
// stop with no error and no log line, because a tick that skips everything
|
||||
// fails nothing.
|
||||
pending, err := w.store.PendingFactResolutions(ctx, enrichmentScanLimit)
|
||||
if err != nil {
|
||||
log.Printf("factenrichment: list pending: %v", err)
|
||||
return
|
||||
}
|
||||
w.forgetDeparted(pending)
|
||||
skipped, failed, attempted := 0, 0, 0
|
||||
for _, f := range pending {
|
||||
w.resolveOne(ctx, f)
|
||||
if attempted >= w.batch {
|
||||
break
|
||||
}
|
||||
if !w.due(f.ID) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
attempted++
|
||||
if !w.resolveOne(ctx, f) {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
log.Printf("factenrichment: %d/%d resolutions failed this tick, %d held in backoff",
|
||||
failed, attempted, skipped)
|
||||
}
|
||||
// Report the backlog every tick, not only when something failed: the
|
||||
// stalled state worth seeing is the one where nothing failed because
|
||||
// nothing was attempted.
|
||||
if st := w.status(ctx); st.Pending > 0 {
|
||||
log.Printf("factenrichment: %d facts pending entity resolution, %d in backoff, worst attempt %d (scanned %d)",
|
||||
st.Pending, st.InBackoff, st.MaxAttempts, st.Scanned)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *factEnrichmentWorker) resolveOne(ctx context.Context, f store.Fact) {
|
||||
// forgetDeparted drops retry state for facts that are no longer pending. A
|
||||
// fact can leave the queue without ever resolving here — voided, or resolved
|
||||
// by a later write — and its entries would otherwise live as long as the
|
||||
// process does.
|
||||
func (w *factEnrichmentWorker) forgetDeparted(pending []store.Fact) {
|
||||
live := make(map[int64]struct{}, len(pending))
|
||||
for _, f := range pending {
|
||||
live[f.ID] = struct{}{}
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
for id := range w.attempt {
|
||||
if _, ok := live[id]; !ok {
|
||||
delete(w.attempt, id)
|
||||
}
|
||||
}
|
||||
for id := range w.nextTry {
|
||||
if _, ok := live[id]; !ok {
|
||||
delete(w.nextTry, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// due reports whether a fact's backoff window has elapsed.
|
||||
func (w *factEnrichmentWorker) due(id int64) bool {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
next, ok := w.nextTry[id]
|
||||
return !ok || !w.now().Before(next)
|
||||
}
|
||||
|
||||
// resolveOne resolves one pending fact. It returns false when the attempt
|
||||
// failed transiently: the fact stays pending and is retried on a backoff.
|
||||
func (w *factEnrichmentWorker) resolveOne(ctx context.Context, f store.Fact) bool {
|
||||
entityID, _, ambiguous, err := w.eco.resolveEntityReference(ctx, f.Subject, nil)
|
||||
if err != nil {
|
||||
// Transient (Nexus unreachable) — leave pending, retry next tick.
|
||||
log.Printf("factenrichment: resolve fact %d subject %q: %v", f.ID, f.Subject, err)
|
||||
return
|
||||
// Transient (Nexus unreachable) — leave pending, back off, retry later.
|
||||
// The subject is his words: log its length, the way the trace does.
|
||||
log.Printf("factenrichment: resolve fact %d subject %s: %v", f.ID, redactSubject(f.Subject), err)
|
||||
w.backOff(f.ID)
|
||||
return false
|
||||
}
|
||||
state := store.ResolutionNotFound
|
||||
switch {
|
||||
@@ -77,6 +220,25 @@ func (w *factEnrichmentWorker) resolveOne(ctx context.Context, f store.Fact) {
|
||||
state = store.ResolutionAmbiguous
|
||||
}
|
||||
if err := w.store.ResolveFactEntity(ctx, f.ID, entityID, state); err != nil {
|
||||
// A failed write leaves the fact pending exactly like a failed resolve
|
||||
// does, so it gets the same pacing. Clearing the counters first meant
|
||||
// this one path retried every tick, at full rate, with no ceiling.
|
||||
log.Printf("factenrichment: record resolution for fact %d: %v", f.ID, err)
|
||||
w.backOff(f.ID)
|
||||
return false
|
||||
}
|
||||
w.mu.Lock()
|
||||
delete(w.attempt, f.ID)
|
||||
delete(w.nextTry, f.ID)
|
||||
w.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
// backOff records one more consecutive failure for a fact and pushes its next
|
||||
// attempt out accordingly.
|
||||
func (w *factEnrichmentWorker) backOff(id int64) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.attempt[id]++
|
||||
w.nextTry[id] = w.now().Add(enrichmentBackoff(w.attempt[id]))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
type capturedRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
Query string
|
||||
Body []byte
|
||||
Header http.Header
|
||||
}
|
||||
|
||||
// fakeServer is the common shell behind fakeNexus/fakePraxis/fakeHexis: an
|
||||
@@ -25,9 +27,12 @@ type capturedRequest struct {
|
||||
type fakeServer struct {
|
||||
*httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
requests []capturedRequest
|
||||
fault int // non-zero: every request gets this HTTP status instead of routing
|
||||
mu sync.Mutex
|
||||
requests []capturedRequest
|
||||
fault int // non-zero: every request gets this HTTP status instead of routing
|
||||
routeFaults map[string]int // path prefix → status, for one endpoint failing alone
|
||||
garbage string // non-empty: returned 200 verbatim instead of routing (malformed-contract lever)
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
// newFakeServer starts a server dispatching to routes keyed by "METHOD
|
||||
@@ -47,14 +52,42 @@ func newFakeServer(t *testing.T, routes map[string]http.HandlerFunc) *fakeServer
|
||||
}
|
||||
}
|
||||
fs.mu.Lock()
|
||||
fs.requests = append(fs.requests, capturedRequest{Method: r.Method, Path: r.URL.Path, Body: body})
|
||||
fs.requests = append(fs.requests, capturedRequest{
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Query: r.URL.RawQuery,
|
||||
Body: body,
|
||||
Header: r.Header.Clone(),
|
||||
})
|
||||
fault := fs.fault
|
||||
if fault == 0 {
|
||||
for prefix, status := range fs.routeFaults {
|
||||
if hasPrefix(r.URL.Path, prefix) {
|
||||
fault = status
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
garbage := fs.garbage
|
||||
delay := fs.delay
|
||||
fs.mu.Unlock()
|
||||
|
||||
if delay > 0 {
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if fault != 0 {
|
||||
http.Error(w, "injected fault", fault)
|
||||
return
|
||||
}
|
||||
if garbage != "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(garbage))
|
||||
return
|
||||
}
|
||||
|
||||
for key, handler := range routes {
|
||||
method, prefix := splitRouteKey(key)
|
||||
@@ -90,6 +123,52 @@ func (fs *fakeServer) SetFault(status int) {
|
||||
fs.fault = status
|
||||
}
|
||||
|
||||
// SetRouteFault fails one endpoint while the rest of the server stays healthy,
|
||||
// which is the shape most real outages take: attention answers and pin is
|
||||
// down. Pass 0 to clear that route. A server-wide SetFault still wins.
|
||||
func (fs *fakeServer) SetRouteFault(pathPrefix string, status int) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
if fs.routeFaults == nil {
|
||||
fs.routeFaults = map[string]int{}
|
||||
}
|
||||
if status == 0 {
|
||||
delete(fs.routeFaults, pathPrefix)
|
||||
return
|
||||
}
|
||||
fs.routeFaults[pathPrefix] = status
|
||||
}
|
||||
|
||||
// SetBody makes every subsequent request answer 200 with the given body,
|
||||
// bypassing the route table. Used to serve a malformed or contract-violating
|
||||
// payload where the transport itself is healthy. Pass "" to clear it.
|
||||
func (fs *fakeServer) SetBody(body string) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
fs.garbage = body
|
||||
}
|
||||
|
||||
// SetDelay stalls every subsequent request for d before answering, so callers
|
||||
// can drive client timeouts and context cancellation deterministically. The
|
||||
// delay is abandoned as soon as the client hangs up.
|
||||
func (fs *fakeServer) SetDelay(d time.Duration) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
fs.delay = d
|
||||
}
|
||||
|
||||
// Count returns how many captured requests used the given method and path
|
||||
// prefix. "" matches any method.
|
||||
func (fs *fakeServer) Count(method, prefix string) int {
|
||||
n := 0
|
||||
for _, r := range fs.Requests() {
|
||||
if (method == "" || r.Method == method) && hasPrefix(r.Path, prefix) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Requests returns a snapshot of captured requests, in arrival order.
|
||||
func (fs *fakeServer) Requests() []capturedRequest {
|
||||
fs.mu.Lock()
|
||||
@@ -118,6 +197,40 @@ func fixtureNexusResolved(entityID, displayName, entityType string) string {
|
||||
})
|
||||
}
|
||||
|
||||
// fixtureNexusResolvedFlat is the flat resolve shape documented in
|
||||
// ECOSYSTEM-SPEC.md §1.5 (entity_id/entity_type/display_name at the top
|
||||
// level) rather than the nested "entity" object — the older of the two
|
||||
// wire shapes Maven must keep accepting.
|
||||
func fixtureNexusResolvedFlat(entityID, displayName, entityType string) string {
|
||||
return mustJSON(map[string]any{
|
||||
"status": "resolved",
|
||||
"entity_id": entityID,
|
||||
"entity_type": entityType,
|
||||
"display_name": displayName,
|
||||
})
|
||||
}
|
||||
|
||||
// fixtureNexusResolvedFuture is a resolved response from a hypothetical newer
|
||||
// Nexus: same required fields plus unknown ones. Decoding must ignore the
|
||||
// extras, not fail — forward compatibility is what lets the ecosystem be
|
||||
// upgraded one service at a time.
|
||||
func fixtureNexusResolvedFuture(entityID, displayName, entityType string) string {
|
||||
return mustJSON(map[string]any{
|
||||
"status": "resolved",
|
||||
"entity": map[string]any{"id": entityID, "display_name": displayName, "type": entityType, "tenant": "home"},
|
||||
"provenance": map[string]any{"resolver": "v3", "graph_epoch": 42},
|
||||
"score_breakdown": []any{map[string]any{"signal": "alias", "weight": 0.9}},
|
||||
})
|
||||
}
|
||||
|
||||
// fixtureNexusResolvedEmpty is the contract violation that decodes cleanly:
|
||||
// Nexus claims a resolve and delivers no entity. It must not read as "no such
|
||||
// entity", which would let the caller fall through to local execution with the
|
||||
// user's verb intact.
|
||||
func fixtureNexusResolvedEmpty() string {
|
||||
return `{"status":"resolved"}`
|
||||
}
|
||||
|
||||
func fixtureNexusNotFound() string {
|
||||
return `{"status":"not_found"}`
|
||||
}
|
||||
@@ -138,6 +251,23 @@ func fixtureHexisExecuted(id, status string) string {
|
||||
return mustJSON(map[string]any{"id": id, "status": status})
|
||||
}
|
||||
|
||||
// fixtureHexisExecutionFailed is a well-formed Hexis response reporting that
|
||||
// the command itself failed: the call succeeded, the execution did not. Maven
|
||||
// must distinguish this from a transport failure and from success.
|
||||
func fixtureHexisExecutionFailed(id, message string) string {
|
||||
return mustJSON(map[string]any{"id": id, "status": "failed", "error": message})
|
||||
}
|
||||
|
||||
// fixturePraxisAttentionScoped tags each item with an entity_id, which is what
|
||||
// a Praxis that understands the entity_id query parameter returns. A Praxis
|
||||
// that ignores it answers with untagged items from every entity.
|
||||
func fixturePraxisAttentionScoped(entityID string, items ...map[string]any) string {
|
||||
for _, item := range items {
|
||||
item["entity_id"] = entityID
|
||||
}
|
||||
return mustJSON(items)
|
||||
}
|
||||
|
||||
func fixturePraxisAttentionItems(items ...map[string]any) string {
|
||||
return mustJSON(items)
|
||||
}
|
||||
@@ -156,18 +286,28 @@ func mustJSON(v any) string {
|
||||
// (e.g. asserting age-based digest ordering without sleeping).
|
||||
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
step time.Duration // advanced on every read, so elapsed time is measurable
|
||||
}
|
||||
|
||||
func newFakeClock(start time.Time) *fakeClock {
|
||||
return &fakeClock{t: start}
|
||||
}
|
||||
|
||||
// newTickingClock advances by step on every read. Durations measured across
|
||||
// hops are then non-zero without sleeping, which is what lets a test tell a
|
||||
// trace that measured something from one that measured nothing.
|
||||
func newTickingClock(start time.Time, step time.Duration) *fakeClock {
|
||||
return &fakeClock{t: start, step: step}
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
now := c.t
|
||||
c.t = c.t.Add(c.step)
|
||||
return now
|
||||
}
|
||||
|
||||
func (c *fakeClock) Advance(d time.Duration) {
|
||||
@@ -191,8 +331,13 @@ func newFakeNexus(t *testing.T, resolveBody string) *fakeServer {
|
||||
// fault is injected via SetFault.
|
||||
func newFakePraxis(t *testing.T, attentionBody string) *fakeServer {
|
||||
return newFakeServer(t, map[string]http.HandlerFunc{
|
||||
"GET /api/v1/tools/attention": jsonHandler(http.StatusOK, attentionBody),
|
||||
"POST /api/v1/tools/surface": jsonHandler(http.StatusOK, `{}`),
|
||||
"GET /api/v1/tools/attention": jsonHandler(http.StatusOK, attentionBody),
|
||||
"GET /api/v1/tools/changes": jsonHandler(http.StatusOK, `[]`),
|
||||
"POST /api/v1/tools/surface": jsonHandler(http.StatusOK, `{}`),
|
||||
"POST /api/v1/tools/acknowledge": jsonHandler(http.StatusOK, `{}`),
|
||||
"POST /api/v1/tools/resolve": jsonHandler(http.StatusOK, `{}`),
|
||||
"POST /api/v1/tools/ignore": jsonHandler(http.StatusOK, `{}`),
|
||||
"POST /api/v1/tools/pin": jsonHandler(http.StatusOK, `{}`),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/rss"
|
||||
"github.com/kami/maven/internal/stt"
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
@@ -116,6 +117,17 @@ func embedderOf(w *voiceWiring) router.Embedder {
|
||||
return w.embedder
|
||||
}
|
||||
|
||||
// transcriberOf — the STT the voice path is using, or nil when voice is off.
|
||||
// The meeting recorder reuses it rather than dialling mavsttd a second time:
|
||||
// Maven has one speech-to-text engine and adding a second would mean two
|
||||
// whisper contexts competing for the same iGPU.
|
||||
func transcriberOf(w *voiceWiring) stt.Transcriber {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.transcriber
|
||||
}
|
||||
|
||||
// feedFetcher adapts webfetch to rss.Fetcher — the pure package names the two
|
||||
// fields it needs and stays free of net/http.
|
||||
type feedFetcher struct{ f *webfetch.Fetcher }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
// mavend/intake.go — the unified event intake envelope, wired (Vikunja #283).
|
||||
//
|
||||
// internal/event defines the envelope and the bounded in-memory journal. This
|
||||
// file is the one place that FILLS it, and the reason it is one place is worth
|
||||
// stating, because the alternative was eight patches:
|
||||
//
|
||||
// Every intake path in Maven already converges on three writes, and all three
|
||||
// are ipc.CoreAPI methods —
|
||||
//
|
||||
// WriteFact ← POST /api/ambient, mavcaldav, mavpoll's zenmoney + wg reads,
|
||||
// /api/signal presence probes, the RSS/crawl watermarks
|
||||
// WriteNote ← the RSS poller, the page crawler, meeting transcripts,
|
||||
// image descriptions
|
||||
// CaptureTask ← the voice path, the web form, and the mail reader
|
||||
//
|
||||
// — so decorating that ONE interface with a publish covers the lot without a
|
||||
// caller knowing about events at all. cmd/mavmaild, cmd/mavcaldav, cmd/mavpoll,
|
||||
// cmd/mavweb and the in-core feed/crawl/capture/vision workers are unchanged:
|
||||
// they call the same interface they always called, and it now also narrates.
|
||||
//
|
||||
// The exception is cmd/mavend/mail.go, which reaches past the interface to
|
||||
// st.CaptureTask directly. It publishes explicitly; see mailIntake.ingest.
|
||||
//
|
||||
// # Production behaviour when nobody is watching
|
||||
//
|
||||
// A nil *event.Bus makes Publish a no-op, and newIntakeAPI with a nil bus
|
||||
// returns the wrapped API unchanged, so there is not even a decorator on the
|
||||
// call path. The journal is memory-only and is never consulted by the tick
|
||||
// loop, the router, or delivery — nothing Maven says depends on it. It is a
|
||||
// read surface (`/events`, `recent_events`) and an observation seam for the
|
||||
// simulator.
|
||||
//
|
||||
// # What is deliberately NOT here
|
||||
//
|
||||
// No dispatch. An event is a report that something arrived, never an
|
||||
// instruction to speak: "a feed item appeared" becoming a notification is the
|
||||
// nag this repo refuses. Digestion may one day read the journal; it will still
|
||||
// go through internal/loop's rules and the severity/presence routing table.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/event"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// newEventBus builds the journal, or returns nil when the operator turned it
|
||||
// off (a negative config.intake_journal). nil is the "behave exactly as before"
|
||||
// value all the way down: no decorator, no ring, no /events rows.
|
||||
func newEventBus(cfg *config.Config) *event.Bus {
|
||||
if cfg == nil {
|
||||
// No config at all is a test, not an operator decision. Saying "off"
|
||||
// here was noise in every suite that passes nil.
|
||||
return nil
|
||||
}
|
||||
if cfg.IntakeJournal < 0 {
|
||||
log.Printf("intake journal: off (intake_journal < 0)")
|
||||
return nil
|
||||
}
|
||||
n := cfg.IntakeJournal
|
||||
if n == 0 {
|
||||
n = config.DefaultIntakeJournal
|
||||
}
|
||||
log.Printf("intake journal: keeping the last %d intake events in memory", n)
|
||||
return event.NewBus(n)
|
||||
}
|
||||
|
||||
// intakeEventsFn is the daemonAPI.getEvents closure: the bus's ring rendered as
|
||||
// the wire type. Returns nil for a nil bus, which the daemonAPI reports as an
|
||||
// empty journal rather than an error.
|
||||
func intakeEventsFn(bus *event.Bus) func(n int) []ipc.IntakeEvent {
|
||||
if bus == nil {
|
||||
return nil
|
||||
}
|
||||
return func(n int) []ipc.IntakeEvent {
|
||||
evs := bus.Recent(n)
|
||||
out := make([]ipc.IntakeEvent, 0, len(evs))
|
||||
for _, e := range evs {
|
||||
out = append(out, ipc.IntakeEvent{
|
||||
Source: e.Source,
|
||||
Kind: e.Kind,
|
||||
EntityIDs: e.EntityIDs,
|
||||
Title: e.Title,
|
||||
Body: e.Body,
|
||||
Priority: e.Priority,
|
||||
OccurredAt: e.OccurredAt,
|
||||
NoticedAt: e.NoticedAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// intakeAPI decorates a CoreAPI, publishing one envelope per successful
|
||||
// intake write. Embedding the interface means every other method passes
|
||||
// through untouched, and a new CoreAPI method is inherited rather than
|
||||
// silently dropped.
|
||||
type intakeAPI struct {
|
||||
ipc.CoreAPI
|
||||
bus *event.Bus
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// newIntakeAPI wraps api so its intake writes are journalled. A nil bus
|
||||
// returns api itself — no decorator, no allocation, no behaviour change.
|
||||
func newIntakeAPI(api ipc.CoreAPI, bus *event.Bus, now func() time.Time) ipc.CoreAPI {
|
||||
if bus == nil || api == nil {
|
||||
return api
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &intakeAPI{CoreAPI: api, bus: bus, now: now}
|
||||
}
|
||||
|
||||
// WriteFact journals the fact after it lands. Order matters: an event is a
|
||||
// report of something that HAPPENED, so a failed write publishes nothing.
|
||||
func (a *intakeAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||
id, err := a.CoreAPI.WriteFact(ctx, req)
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
if selfWrite(req) {
|
||||
// Maven's own bookkeeping is not something that arrived. The feed
|
||||
// watermark, the crawl hash, the praxis trace of an act she performed
|
||||
// and a quiet-hours toggle he pressed all used to sit on a page headed
|
||||
// "everything that arrived", and on a cold start a handful of feeds
|
||||
// could evict real intake behind their marks.
|
||||
return id, nil
|
||||
}
|
||||
// OccurredAt is req.Ts, not now: mavpoll's wg read carries the handshake
|
||||
// instant and the ambient path carries the meeting's start. Flattening
|
||||
// those to notice-time would make the journal lie about when things
|
||||
// happened, which is the one thing it is for.
|
||||
title := req.Key
|
||||
if req.VoidsID != nil {
|
||||
// A retraction is not a reading. Without this it published an envelope
|
||||
// indistinguishable from a fresh value for the same key, on a page
|
||||
// whose whole job is "what came in".
|
||||
title = "отмена: " + req.Key
|
||||
}
|
||||
a.bus.Publish(event.Event{
|
||||
Source: req.Source,
|
||||
Kind: event.SourceKind(req.Source, event.KindFact),
|
||||
Title: title,
|
||||
Body: req.Value,
|
||||
Priority: factPriority(req),
|
||||
OccurredAt: req.Ts,
|
||||
EntityIDs: entityIDs(req.Subject),
|
||||
Payload: factPayload(req),
|
||||
}, a.now())
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// WriteNote journals a note. This is the RSS and crawler path, and also the
|
||||
// meeting transcript and image description paths, which write their derived
|
||||
// text as ordinary notes.
|
||||
func (a *intakeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||
id, err := a.CoreAPI.WriteNote(ctx, ts, text, embedding, source)
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
title, body := splitFirstLine(text)
|
||||
a.bus.Publish(event.Event{
|
||||
Source: source,
|
||||
Kind: event.SourceKind(source, event.KindNote),
|
||||
Title: title,
|
||||
Body: body,
|
||||
Priority: event.PriorityLow,
|
||||
OccurredAt: ts,
|
||||
}, a.now())
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// CaptureTask journals a captured task, but only when a row was actually
|
||||
// created. CaptureTask dedupes on normalised text among live rows, so a
|
||||
// mailbox re-read after a restart must not refill the journal with tasks that
|
||||
// were already there.
|
||||
func (a *intakeAPI) CaptureTask(ctx context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||
resp, err := a.CoreAPI.CaptureTask(ctx, req)
|
||||
if err != nil || !resp.Created {
|
||||
return resp, err
|
||||
}
|
||||
a.bus.Publish(publishableTask(store.Task{
|
||||
CreatedTs: req.Ts,
|
||||
Text: req.Text,
|
||||
Source: req.Source,
|
||||
Evidence: req.Evidence,
|
||||
Status: req.Status,
|
||||
Due: req.Due,
|
||||
}, a.now()), a.now())
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// publishableTask is the task→envelope shape, shared with mail.go, which
|
||||
// captures through the store directly rather than through the interface.
|
||||
//
|
||||
// Priority is high for a candidate with a due date and normal otherwise. That
|
||||
// is the only place this file makes a judgement, and it is a display hint on a
|
||||
// review page — nothing routes on it.
|
||||
func publishableTask(t store.Task, now time.Time) event.Event {
|
||||
occurred := t.CreatedTs
|
||||
if occurred.IsZero() {
|
||||
occurred = now
|
||||
}
|
||||
prio := event.PriorityNormal
|
||||
if t.Due != nil {
|
||||
prio = event.PriorityHigh
|
||||
}
|
||||
return event.Event{
|
||||
Source: t.Source,
|
||||
Kind: event.KindTask,
|
||||
Title: t.Text,
|
||||
Body: t.Evidence,
|
||||
Priority: prio,
|
||||
OccurredAt: occurred,
|
||||
}
|
||||
}
|
||||
|
||||
// selfWrite reports whether a fact write is Maven describing her own state
|
||||
// rather than something arriving from outside. The store's fact kinds are
|
||||
// 'self', 'env' and 'config'; 'config' is where every watermark and toggle
|
||||
// lands, and the praxis trace is an audit record of an act she performed, which
|
||||
// is the same class of thing under an 'env' kind.
|
||||
func selfWrite(req ipc.WriteFactReq) bool {
|
||||
switch req.Kind {
|
||||
case "config", "system":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(req.Source, "praxis:trace")
|
||||
}
|
||||
|
||||
// factPriority is the attention hint for a fact write. Deliberately crude:
|
||||
// a low-confidence inference (the ambient notification path writes below 1.0)
|
||||
// is worth less attention than a read he or a credentialled poller made, and a
|
||||
// retraction is a correction rather than news.
|
||||
//
|
||||
// Confidence is NOT recoverable from this, which is why the number itself goes
|
||||
// into Payload: three display buckets must not be the only surviving trace of
|
||||
// the distinction internal/calendar went out of its way to keep.
|
||||
func factPriority(req ipc.WriteFactReq) string {
|
||||
if req.VoidsID != nil {
|
||||
return event.PriorityLow
|
||||
}
|
||||
if req.Confidence > 0 && req.Confidence < 1.0 {
|
||||
return event.PriorityLow
|
||||
}
|
||||
return event.PriorityNormal
|
||||
}
|
||||
|
||||
// factDetail is the fact-shaped Payload: the fields the envelope's own flat
|
||||
// shape cannot carry, kept so a reader can tell an inference from a
|
||||
// credentialled read, and "nobody said" from "certain".
|
||||
type factDetail struct {
|
||||
// FactKind — the fact's own kind ('self', 'env', 'config'), a different
|
||||
// taxonomy from Event.Kind.
|
||||
FactKind string `json:"fact_kind,omitempty"`
|
||||
// Confidence — the number itself, so an inference stays distinguishable
|
||||
// from a credentialled read. nil when the writer set none, which the ipc
|
||||
// layer rejects today; the pointer keeps "nobody said" and "certain" from
|
||||
// collapsing into each other the way the priority bucket does.
|
||||
Confidence *float64 `json:"confidence,omitempty"`
|
||||
// VoidsID — the fact this one retracts.
|
||||
VoidsID *int64 `json:"voids_id,omitempty"`
|
||||
}
|
||||
|
||||
func factPayload(req ipc.WriteFactReq) json.RawMessage {
|
||||
d := factDetail{FactKind: req.Kind, VoidsID: req.VoidsID}
|
||||
if req.Confidence != 0 {
|
||||
c := req.Confidence
|
||||
d.Confidence = &c
|
||||
}
|
||||
if d.FactKind == "" && d.Confidence == nil && d.VoidsID == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// entityIDs turns a fact's free-text Subject into the EntityIDs slot when it
|
||||
// already looks resolved. Intake runs BEFORE the fact enrichment worker
|
||||
// resolves a subject against Nexus, so this is almost always empty — the slot
|
||||
// exists for the paths that do know (the ecosystem acts), not for guessing.
|
||||
func entityIDs(subject string) []string {
|
||||
subject = strings.TrimSpace(subject)
|
||||
if subject == "" || !strings.HasPrefix(subject, "entity:") {
|
||||
return nil
|
||||
}
|
||||
return []string{strings.TrimPrefix(subject, "entity:")}
|
||||
}
|
||||
|
||||
// splitFirstLine renders a note as title + body. Feed and crawl notes are
|
||||
// written "headline\nsummary\nlink", so the first line is already the title.
|
||||
func splitFirstLine(text string) (title, body string) {
|
||||
text = strings.TrimSpace(text)
|
||||
if i := strings.IndexByte(text, '\n'); i >= 0 {
|
||||
return strings.TrimSpace(text[:i]), strings.TrimSpace(text[i+1:])
|
||||
}
|
||||
return text, ""
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/event"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
var intakeNow = time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
func intakeClock() time.Time { return intakeNow }
|
||||
|
||||
// failingAPI wraps the store adapter, failing the three intake writes on
|
||||
// demand, so the "a failed write publishes nothing" invariant is testable.
|
||||
type failingAPI struct {
|
||||
ipc.CoreAPI
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (f *failingAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||
if f.fail {
|
||||
return 0, errors.New("injected")
|
||||
}
|
||||
return f.CoreAPI.WriteFact(ctx, req)
|
||||
}
|
||||
|
||||
func newIntakeTestAPI(t *testing.T) (ipc.CoreAPI, *event.Bus) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
bus := event.NewBus(32)
|
||||
return newIntakeAPI(ipc.NewStoreAPI(st), bus, intakeClock), bus
|
||||
}
|
||||
|
||||
func TestIntakeAPIWithoutBusIsTheBareAPI(t *testing.T) {
|
||||
// The adoption invariant: with the journal off there is not even a
|
||||
// decorator on the intake path, so production behaves exactly as before.
|
||||
st := newTestStore(t)
|
||||
bare := ipc.NewStoreAPI(st)
|
||||
if got := newIntakeAPI(bare, nil, intakeClock); got != ipc.CoreAPI(bare) {
|
||||
t.Errorf("newIntakeAPI with a nil bus returned a wrapper, want the bare API")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEventBusOffWhenNegative(t *testing.T) {
|
||||
if b := newEventBus(&config.Config{IntakeJournal: -1}); b != nil {
|
||||
t.Error("intake_journal = -1 still built a bus")
|
||||
}
|
||||
if b := newEventBus(&config.Config{IntakeJournal: 4}); b == nil {
|
||||
t.Error("intake_journal = 4 built no bus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntakeJournalsAFactWrite(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
// The ambient path's shape: an env fact below full confidence, timestamped
|
||||
// at the meeting's start rather than at notice time.
|
||||
start := intakeNow.Add(2 * time.Hour)
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: start, Kind: "env", Key: "calendar_event_20260801_планёрка",
|
||||
Value: "10:00-11:00 планёрка", Source: "ambient:notif", Confidence: 0.6,
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteFact: %v", err)
|
||||
}
|
||||
got := bus.Recent(0)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("journal has %d entries, want 1", len(got))
|
||||
}
|
||||
e := got[0]
|
||||
if e.Source != "ambient:notif" || e.Kind != event.KindFact {
|
||||
t.Errorf("source/kind = %q/%q", e.Source, e.Kind)
|
||||
}
|
||||
if e.Title != "calendar_event_20260801_планёрка" {
|
||||
t.Errorf("title = %q, want the fact key", e.Title)
|
||||
}
|
||||
if !e.OccurredAt.Equal(start) {
|
||||
t.Errorf("occurred_at = %v, want the fact's Ts %v — the journal must not flatten intake to notice time", e.OccurredAt, start)
|
||||
}
|
||||
if e.Priority != event.PriorityLow {
|
||||
t.Errorf("priority = %q, want %q for a sub-1.0 confidence read", e.Priority, event.PriorityLow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntakeDoesNotJournalAFailedWrite(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
bus := event.NewBus(8)
|
||||
api := newIntakeAPI(&failingAPI{CoreAPI: ipc.NewStoreAPI(st), fail: true}, bus, intakeClock)
|
||||
if _, err := api.WriteFact(context.Background(), ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "k", Value: "v", Source: "poll:zenmoney", Confidence: 1,
|
||||
}); err == nil {
|
||||
t.Fatal("expected the injected error")
|
||||
}
|
||||
if bus.Len() != 0 {
|
||||
t.Errorf("journal has %d entries after a failed write, want 0 — an event reports something that happened", bus.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntakeJournalsANoteAsTitlePlusBody(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
// The RSS shape: "headline\nsummary\nlink".
|
||||
if _, err := api.WriteNote(context.Background(), intakeNow,
|
||||
"Вышло ядро 6.19\nкраткое содержание\nhttps://example.org/a", nil, "rss:tech"); err != nil {
|
||||
t.Fatalf("WriteNote: %v", err)
|
||||
}
|
||||
got := bus.Recent(1)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("journal has %d entries, want 1", len(got))
|
||||
}
|
||||
if got[0].Title != "Вышло ядро 6.19" {
|
||||
t.Errorf("title = %q, want the headline", got[0].Title)
|
||||
}
|
||||
if got[0].Kind != event.KindNote {
|
||||
t.Errorf("kind = %q, want %q", got[0].Kind, event.KindNote)
|
||||
}
|
||||
if got[0].Body == "" {
|
||||
t.Error("body is empty, want the rest of the note")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntakeJournalsOnlyCreatedTasks(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
req := ipc.CaptureTaskReq{Text: "оплатить интернет", Source: "email:inbox", Status: "candidate", Ts: intakeNow}
|
||||
if _, err := api.CaptureTask(ctx, req); err != nil {
|
||||
t.Fatalf("CaptureTask: %v", err)
|
||||
}
|
||||
// Same text again: CaptureTask dedupes among live rows, and a re-read of a
|
||||
// mailbox must not refill the journal.
|
||||
resp, err := api.CaptureTask(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureTask (repeat): %v", err)
|
||||
}
|
||||
if resp.Created {
|
||||
t.Fatal("store did not dedupe; the test cannot check what it means to")
|
||||
}
|
||||
if bus.Len() != 1 {
|
||||
t.Errorf("journal has %d entries, want 1 — a deduped capture must not publish", bus.Len())
|
||||
}
|
||||
if got := bus.Recent(1)[0]; got.Kind != event.KindTask || got.Title != "оплатить интернет" {
|
||||
t.Errorf("entry = %+v, want the captured task", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntakeEventsFnRendersNewestFirst(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
for _, key := range []string{"a", "b", "c"} {
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: key, Value: "1", Source: "poll:zenmoney", Confidence: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteFact %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
fn := intakeEventsFn(bus)
|
||||
got := fn(2)
|
||||
if len(got) != 2 || got[0].Title != "c" || got[1].Title != "b" {
|
||||
t.Errorf("intakeEventsFn(2) = %+v, want the two newest, newest first", got)
|
||||
}
|
||||
if intakeEventsFn(nil) != nil {
|
||||
t.Error("intakeEventsFn(nil) returned a closure, want nil so daemonAPI reports an empty journal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonAPIRecentEventsEmptyWithoutABus(t *testing.T) {
|
||||
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}}
|
||||
got, err := d.RecentEvents(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentEvents with no journal errored: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %d events, want none", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// Maven's own bookkeeping is not intake. The feed watermark, the crawl hash,
|
||||
// the praxis trace of an act she performed and the quiet-hours toggle he
|
||||
// pressed all landed on a page headed "everything that arrived", and on a cold
|
||||
// start a handful of feeds could evict real intake behind their marks.
|
||||
func TestIntakeSkipsHerOwnBookkeeping(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
for _, req := range []ipc.WriteFactReq{
|
||||
{Ts: intakeNow, Kind: "config", Key: "rss:latest:tech", Value: "2026-08-01T09:00:00Z", Source: "poll:rss", Confidence: 1.0},
|
||||
{Ts: intakeNow, Kind: "config", Key: "crawl:hash:kernel", Value: "deadbeef", Source: "poll:crawl", Confidence: 1.0},
|
||||
{Ts: intakeNow, Kind: "config", Key: "quiet_hours", Value: "true", Source: "tap:voice", Confidence: 1.0},
|
||||
{Ts: intakeNow, Kind: "env", Key: "praxis:list_attention", Value: "ok", Source: "praxis:trace", Confidence: 1.0},
|
||||
} {
|
||||
if _, err := api.WriteFact(ctx, req); err != nil {
|
||||
t.Fatalf("WriteFact(%s): %v", req.Key, err)
|
||||
}
|
||||
}
|
||||
if n := bus.Len(); n != 0 {
|
||||
t.Fatalf("journalled %d bookkeeping writes, want 0: %+v", n, bus.Recent(0))
|
||||
}
|
||||
// A real arrival under the same decorator still lands.
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "spend_today", Value: "1200",
|
||||
Source: "poll:zenmoney", Confidence: 1.0,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bus.Len() != 1 {
|
||||
t.Fatalf("a real intake write was dropped: %+v", bus.Recent(0))
|
||||
}
|
||||
}
|
||||
|
||||
// Confidence is the distinction between an inference and a credentialled read,
|
||||
// and the three-value priority bucket cannot carry it: unset and 1.0 land in
|
||||
// the same bucket, and 0.6 is gone entirely once mapped. Payload keeps it.
|
||||
func TestIntakeCarriesConfidenceAndFactKind(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "calendar_event_x", Value: "18:00 планёрка",
|
||||
Source: "ambient:notif", Confidence: 0.6,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "self", Key: "mood", Value: "ok", Source: "tap:web", Confidence: 1.0,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := bus.Recent(0)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("journal has %d entries, want 2", len(got))
|
||||
}
|
||||
var relayed, stated factDetail
|
||||
if err := json.Unmarshal(got[1].Payload, &relayed); err != nil {
|
||||
t.Fatalf("payload: %v", err)
|
||||
}
|
||||
if relayed.Confidence == nil || *relayed.Confidence != 0.6 {
|
||||
t.Errorf("confidence = %v, want 0.6 recoverable from the payload", relayed.Confidence)
|
||||
}
|
||||
if relayed.FactKind != "env" {
|
||||
t.Errorf("fact_kind = %q, want env", relayed.FactKind)
|
||||
}
|
||||
// Both writes land in PriorityNormal or PriorityLow buckets that cannot be
|
||||
// told apart from the outside; the payload is where the two numbers stay
|
||||
// distinguishable.
|
||||
if err := json.Unmarshal(got[0].Payload, &stated); err != nil {
|
||||
t.Fatalf("payload: %v", err)
|
||||
}
|
||||
if stated.Confidence == nil || *stated.Confidence != 1.0 || stated.FactKind != "self" {
|
||||
t.Errorf("payload = %+v, want confidence 1.0 and fact_kind self", stated)
|
||||
}
|
||||
}
|
||||
|
||||
// A retraction is not an observation. It used to publish an envelope
|
||||
// indistinguishable from a fresh reading of the same key.
|
||||
func TestIntakeMarksARetraction(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
id, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "weight", Value: "82", Source: "tap:web", Confidence: 1.0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "weight", Value: "81", Source: "tap:web",
|
||||
Confidence: 1.0, VoidsID: &id,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := bus.Recent(1)[0]
|
||||
if e.Priority != event.PriorityLow {
|
||||
t.Errorf("priority = %q, want low for a correction", e.Priority)
|
||||
}
|
||||
if !strings.HasPrefix(e.Title, "отмена:") {
|
||||
t.Errorf("title = %q, want it marked as a retraction", e.Title)
|
||||
}
|
||||
var d factDetail
|
||||
if err := json.Unmarshal(e.Payload, &d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.VoidsID == nil || *d.VoidsID != id {
|
||||
t.Errorf("voids_id = %v, want %d", d.VoidsID, id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
// Writing the wrapped-key blob (Vikunja #14).
|
||||
//
|
||||
// The blob is the only thing that opens the database on a cold-started box, so
|
||||
// the two rules here are about not losing it.
|
||||
//
|
||||
// # It is rewritten on every assertion, so the write must be atomic
|
||||
//
|
||||
// mavweb calls StoreEncryptionKey after every successful assertion, not only
|
||||
// after enrolment. os.WriteFile truncates in place: a power cut or an OOM kill
|
||||
// between the truncate and the write left a zero-length blob and no previous
|
||||
// contents, on the path of every routine step-up. Write to a temp file in the
|
||||
// same directory, fsync it, rename over the target, then fsync the directory.
|
||||
//
|
||||
// # Only one authenticator can hold the cold-start key
|
||||
//
|
||||
// A blob is wrapped under one credential's PRF output and nothing else opens
|
||||
// it. mavweb sends an empty allowCredentials list and the credential store
|
||||
// keeps more than one passkey, so an unconditional rewrite meant the last
|
||||
// authenticator to assert silently locked out every other one — including the
|
||||
// backup hardware key enrolled for exactly the cold-start case. So: a blob
|
||||
// that already opens under this secret and already wraps this key is left
|
||||
// alone, a v1 blob is upgraded in place, and a v2 blob belonging to a
|
||||
// different credential is refused rather than overwritten.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// errForeignBlob — the wrapped key on disk belongs to another credential.
|
||||
// Refusing is the point: overwriting would lock that authenticator out.
|
||||
var errForeignBlob = errors.New("wrapped key belongs to a different credential")
|
||||
|
||||
// wrapKeyToFile wraps key under secret and persists it at path, unless the
|
||||
// blob already there says not to. Reports whether it wrote anything.
|
||||
func wrapKeyToFile(path string, key, secret []byte) (wrote bool, err error) {
|
||||
existing, err := os.ReadFile(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
plain, version, uerr := webauthn.UnwrapKey(existing, secret)
|
||||
switch {
|
||||
case uerr == nil && version == webauthn.BlobV2 && bytes.Equal(plain, key):
|
||||
// Already wrapped under this secret, around this key. The
|
||||
// common case on every assertion after the first.
|
||||
return false, nil
|
||||
case uerr != nil && version == webauthn.BlobV2:
|
||||
return false, fmt.Errorf("%w: %s does not open under this assertion's PRF output, so another passkey holds the cold-start key; delete it deliberately to re-wrap", errForeignBlob, path)
|
||||
}
|
||||
// A v1 blob (upgrade it), or a v2 blob wrapping a stale key under
|
||||
// this same secret (the key was rotated). Both are rewrites.
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
// First wrap.
|
||||
default:
|
||||
return false, fmt.Errorf("read wrapped key: %w", err)
|
||||
}
|
||||
|
||||
blob, err := webauthn.WrapKey(key, secret)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("wrap encryption key: %w", err)
|
||||
}
|
||||
if err := writeFileAtomic(path, blob, 0o600); err != nil {
|
||||
return false, fmt.Errorf("write wrapped key: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// writeFileAtomic writes data to path so that a reader sees either the whole
|
||||
// new file or the whole old one, never a truncated blob.
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
f, err := os.CreateTemp(dir, filepath.Base(path)+".tmp*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := f.Name()
|
||||
defer os.Remove(tmp) // no-op once the rename succeeded
|
||||
|
||||
if err := f.Chmod(perm); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return err
|
||||
}
|
||||
// The rename itself needs to reach the disk, or a crash can resurrect the
|
||||
// old directory entry pointing at a file that is gone.
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
return d.Sync()
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
func wrapPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
return filepath.Join(t.TempDir(), "db_key.wrapped")
|
||||
}
|
||||
|
||||
// The first wrap writes a v2 blob that opens under the same secret.
|
||||
func TestWrapKeyToFileWritesAnOpenableBlob(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
key := bytes.Repeat([]byte{1}, 32)
|
||||
secret := bytes.Repeat([]byte{2}, 32)
|
||||
|
||||
wrote, err := wrapKeyToFile(path, key, secret)
|
||||
if err != nil || !wrote {
|
||||
t.Fatalf("wrapKeyToFile = %v, %v; want a write", wrote, err)
|
||||
}
|
||||
blob, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read blob: %v", err)
|
||||
}
|
||||
plain, version, err := webauthn.UnwrapKey(blob, secret)
|
||||
if err != nil || version != webauthn.BlobV2 || !bytes.Equal(plain, key) {
|
||||
t.Fatalf("UnwrapKey = %x, %v, %v", plain, version, err)
|
||||
}
|
||||
if fi, err := os.Stat(path); err != nil || fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("mode = %v (%v), want 0600", fi.Mode().Perm(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// A blob that already wraps this key under this secret is left alone. Without
|
||||
// this every assertion rewrote the one file that opens the database.
|
||||
func TestWrapKeyToFileSkipsAnIdenticalBlob(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
key := bytes.Repeat([]byte{3}, 32)
|
||||
secret := bytes.Repeat([]byte{4}, 32)
|
||||
|
||||
if _, err := wrapKeyToFile(path, key, secret); err != nil {
|
||||
t.Fatalf("first wrap: %v", err)
|
||||
}
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
wrote, err := wrapKeyToFile(path, key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("second wrap: %v", err)
|
||||
}
|
||||
if wrote {
|
||||
t.Error("rewrote a blob that already opens under this secret")
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Error("the blob changed on a no-op wrap")
|
||||
}
|
||||
}
|
||||
|
||||
// Two enrolled authenticators, two PRF secrets, one blob. The second must not
|
||||
// silently lock the first one out — the backup passkey enrolled for exactly
|
||||
// the cold-start case is the one thing that used to stop working.
|
||||
func TestWrapKeyToFileRefusesAnotherCredentialsBlob(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
key := bytes.Repeat([]byte{5}, 32)
|
||||
phone := bytes.Repeat([]byte{6}, 32)
|
||||
yubikey := bytes.Repeat([]byte{7}, 32)
|
||||
|
||||
if _, err := wrapKeyToFile(path, key, phone); err != nil {
|
||||
t.Fatalf("first wrap: %v", err)
|
||||
}
|
||||
before, _ := os.ReadFile(path)
|
||||
|
||||
wrote, err := wrapKeyToFile(path, key, yubikey)
|
||||
if !errors.Is(err, errForeignBlob) {
|
||||
t.Fatalf("wrapKeyToFile = %v, %v; want errForeignBlob", wrote, err)
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("the second authenticator overwrote the first one's blob")
|
||||
}
|
||||
if _, _, err := webauthn.UnwrapKey(after, phone); err != nil {
|
||||
t.Fatalf("the first authenticator can no longer open the blob: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A v1 blob is the pre-#14 format. It is upgraded in place rather than
|
||||
// refused, because that is the only way off a format that protects nothing.
|
||||
func TestWrapKeyToFileUpgradesALegacyBlob(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
key := bytes.Repeat([]byte{8}, 32)
|
||||
secret := bytes.Repeat([]byte{9}, 32)
|
||||
|
||||
// A v1 blob is a v2 blob with the magic stripped and the v1 info string;
|
||||
// the package writes no v1, so build one the only way a test can: wrap
|
||||
// v2 under a public key, then hand the file a body with no magic. What
|
||||
// matters here is only that UnwrapKey classifies it as v1.
|
||||
v2, err := webauthn.WrapKey(key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapKey: %v", err)
|
||||
}
|
||||
legacy := v2[7:] // drop the magic
|
||||
if err := os.WriteFile(path, legacy, 0o600); err != nil {
|
||||
t.Fatalf("write legacy blob: %v", err)
|
||||
}
|
||||
if _, version, _ := webauthn.UnwrapKey(legacy, secret); version != webauthn.BlobV1 {
|
||||
t.Fatalf("fixture is not read as v1 (got %v)", version)
|
||||
}
|
||||
|
||||
wrote, err := wrapKeyToFile(path, key, secret)
|
||||
if err != nil || !wrote {
|
||||
t.Fatalf("wrapKeyToFile = %v, %v; want the legacy blob upgraded", wrote, err)
|
||||
}
|
||||
blob, _ := os.ReadFile(path)
|
||||
if _, version, err := webauthn.UnwrapKey(blob, secret); err != nil || version != webauthn.BlobV2 {
|
||||
t.Fatalf("after upgrade: version %v, err %v", version, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A rotated at-rest key under the same credential is a rewrite, not a no-op.
|
||||
func TestWrapKeyToFileRewritesARotatedKey(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
secret := bytes.Repeat([]byte{10}, 32)
|
||||
old := bytes.Repeat([]byte{11}, 32)
|
||||
fresh := bytes.Repeat([]byte{12}, 32)
|
||||
|
||||
if _, err := wrapKeyToFile(path, old, secret); err != nil {
|
||||
t.Fatalf("first wrap: %v", err)
|
||||
}
|
||||
wrote, err := wrapKeyToFile(path, fresh, secret)
|
||||
if err != nil || !wrote {
|
||||
t.Fatalf("wrapKeyToFile = %v, %v; want the rotated key written", wrote, err)
|
||||
}
|
||||
blob, _ := os.ReadFile(path)
|
||||
plain, _, err := webauthn.UnwrapKey(blob, secret)
|
||||
if err != nil || !bytes.Equal(plain, fresh) {
|
||||
t.Fatalf("blob still wraps the old key (%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The write never truncates the target in place, so a crash mid-write cannot
|
||||
// leave a zero-length blob where the only copy of the wrapped key was.
|
||||
func TestWriteFileAtomicLeavesNoTempFilesAndReplacesWhole(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "db_key.wrapped")
|
||||
|
||||
if err := os.WriteFile(path, bytes.Repeat([]byte{0xaa}, 67), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
// Hold the old inode. A rename gives it a new one; a truncating write
|
||||
// would keep it.
|
||||
oldInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
|
||||
want := bytes.Repeat([]byte{0xbb}, 67)
|
||||
if err := writeFileAtomic(path, want, 0o600); err != nil {
|
||||
t.Fatalf("writeFileAtomic: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil || !bytes.Equal(got, want) {
|
||||
t.Fatalf("content = %x (%v)", got, err)
|
||||
}
|
||||
newInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if os.SameFile(oldInfo, newInfo) {
|
||||
t.Error("the target was written in place, not renamed over")
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("readdir: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("directory holds %d entries, want just the blob (a temp file leaked)", len(entries))
|
||||
}
|
||||
}
|
||||
+101
-17
@@ -22,10 +22,13 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/email"
|
||||
"github.com/kami/maven/internal/event"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -36,12 +39,46 @@ import (
|
||||
// list into a copy of his mailbox.
|
||||
const evidenceMaxChars = 160
|
||||
|
||||
// captureTimeout — how long the capture writes get, separately from the
|
||||
// extraction budget. A candidate the model already produced must not be lost
|
||||
// because the model was slow.
|
||||
const captureTimeout = 30 * time.Second
|
||||
|
||||
// maxMailboxChars — a mailbox name is an IMAP folder, not free text. It ends up
|
||||
// in the provenance string, which is a small controlled vocabulary.
|
||||
const maxMailboxChars = 64
|
||||
|
||||
// validMailbox checks the name this method is willing to write provenance for.
|
||||
// Empty is refused: "email:" is not a source. So is anything with a control
|
||||
// character or a space-only value, so the source string stays greppable and
|
||||
// stays one token.
|
||||
func validMailbox(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", fmt.Errorf("mail intake: mailbox is required")
|
||||
}
|
||||
if len([]rune(s)) > maxMailboxChars {
|
||||
return "", fmt.Errorf("mail intake: mailbox name too long")
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < 0x20 || r == 0x7f || unicode.IsSpace(r) {
|
||||
return "", fmt.Errorf("mail intake: mailbox name has whitespace or a control character")
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// mailIntake — extraction + capture for one message at a time.
|
||||
type mailIntake struct {
|
||||
st *store.Store
|
||||
ex *email.Extractor
|
||||
timeout time.Duration
|
||||
now func() time.Time
|
||||
// bus — the unified intake journal (Vikunja #283). This path captures
|
||||
// through the store directly rather than through ipc.CoreAPI, so the
|
||||
// decorator in intake.go does not see it and the publish is explicit here.
|
||||
// nil is a working no-op.
|
||||
bus *event.Bus
|
||||
}
|
||||
|
||||
// newMailIntake returns nil when mail ingestion must not be available, which is
|
||||
@@ -52,22 +89,32 @@ type mailIntake struct {
|
||||
// no keyword fallback: "the subject line became a task" is not extraction,
|
||||
// it is a mailbox rendered as a to-do list, and it would fill the review
|
||||
// page faster than he could clear it.
|
||||
func newMailIntake(st *store.Store, phr phraser.Phraser, cfg *config.Config) *mailIntake {
|
||||
func newMailIntake(st *store.Store, phr phraser.Phraser, cfg *config.Config, bus *event.Bus) *mailIntake {
|
||||
if cfg.Email == nil {
|
||||
return nil
|
||||
}
|
||||
lp, ok := phr.(*phraser.LLMPhraser)
|
||||
if !ok {
|
||||
log.Printf("mail intake: configured but no llama-server phraser — mail ingestion disabled")
|
||||
// The phraser is not an *LLMPhraser. Today that means there is no
|
||||
// llama-server; if anything ever WRAPS the phraser it will mean that
|
||||
// instead, so the line names the assertion rather than guessing why.
|
||||
log.Printf("mail intake: configured but the phraser is not an *phraser.LLMPhraser (%T) — mail ingestion disabled", phr)
|
||||
return nil
|
||||
}
|
||||
timeout := time.Duration(cfg.Email.Timeout)
|
||||
if timeout <= 0 {
|
||||
timeout = config.DefaultEmailTimeout
|
||||
}
|
||||
ex := email.NewExtractor(llmClientFor(lp, timeout), cfg.Email.MaxTasks, contextBlockFn(cfg, time.Now))
|
||||
log.Printf("mail intake: enabled (max %d candidates per message, timeout %s)", cfg.Email.MaxTasks, timeout)
|
||||
return &mailIntake{st: st, ex: ex, timeout: timeout, now: time.Now}
|
||||
// Background client: extraction is a job nobody is waiting on, and it shares
|
||||
// one llama-server slot with the voice turn. Through the gate it yields to
|
||||
// anything he is waiting for and only one extraction runs at a time, so a
|
||||
// first poll of 25 unseen messages cannot queue 25 model calls in front of
|
||||
// him. See llm.Gate.
|
||||
ex := email.NewExtractor(llmBackgroundClientFor(lp, timeout), cfg.Email.MaxTasks, contextBlockFn(cfg, time.Now))
|
||||
// The NORMALISED bound, not the configured one: with "email": {} in
|
||||
// mavend.json the configured value is 0 and the daemon allows three.
|
||||
log.Printf("mail intake: enabled (max %d candidates per message, timeout %s)", ex.Max(), timeout)
|
||||
return &mailIntake{st: st, ex: ex, timeout: timeout, now: time.Now, bus: bus}
|
||||
}
|
||||
|
||||
// ingest handles one ipc.MethodIngestMail call.
|
||||
@@ -76,10 +123,19 @@ func newMailIntake(st *store.Store, phr phraser.Phraser, cfg *config.Config) *ma
|
||||
// reader's header filter is what keeps the resident model off newsletters.
|
||||
//
|
||||
// Every candidate is captured with Status "candidate", Source "email:<mailbox>"
|
||||
// and the subject as Evidence. CaptureTask dedupes on normalised text among
|
||||
// live rows, so a mailbox re-read after a restart produces Created=0 rather
|
||||
// than a second copy of every task.
|
||||
// and the subject as Evidence, under an ExternalID naming the message and the
|
||||
// span it was extracted from. That key is unique over every row whatever its
|
||||
// status, so a mailbox re-read after a restart produces Created=0 — and, more
|
||||
// to the point, a task he already marked done is not re-proposed the next time
|
||||
// the same unread message is read again.
|
||||
func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error) {
|
||||
// The mailbox name becomes provenance ("email:INBOX"), and the source
|
||||
// vocabulary is what the loop's rules trust. An empty name gave "email:" and
|
||||
// an arbitrary string gave an arbitrary source under that namespace.
|
||||
mailbox, err := validMailbox(req.Mailbox)
|
||||
if err != nil {
|
||||
return ipc.IngestMailResp{}, err
|
||||
}
|
||||
msg := email.Message{
|
||||
UID: req.UID,
|
||||
From: req.From,
|
||||
@@ -92,9 +148,15 @@ func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.Ing
|
||||
return ipc.IngestMailResp{Skipped: true}, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, m.timeout)
|
||||
defer cancel()
|
||||
cands, err := m.ex.Extract(ctx, msg)
|
||||
// The timeout scopes the EXTRACTION and nothing else. It used to wrap the
|
||||
// capture writes too, so a model that answered at 119 seconds of a 120
|
||||
// second budget left the first CaptureTask one second and the third none:
|
||||
// the work was done, the answer was good, and it was dropped with a
|
||||
// deadline error. Config calls this a per-message extraction budget, and now
|
||||
// it is one.
|
||||
exCtx, cancel := context.WithTimeout(ctx, m.timeout)
|
||||
cands, err := m.ex.Extract(exCtx, msg)
|
||||
cancel()
|
||||
if err != nil {
|
||||
// The error from internal/email never carries mail text; keep it that way
|
||||
// by not adding the subject here.
|
||||
@@ -104,7 +166,13 @@ func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.Ing
|
||||
return ipc.IngestMailResp{}, nil
|
||||
}
|
||||
|
||||
source := email.SourcePrefix + req.Mailbox
|
||||
// A fresh budget for the writes, derived from the caller's context rather
|
||||
// than from the extraction's. Encrypted-store writes are fast; what this
|
||||
// bounds is a stuck store, not the model.
|
||||
ctx, cancel = context.WithTimeout(ctx, captureTimeout)
|
||||
defer cancel()
|
||||
|
||||
source := email.SourcePrefix + mailbox
|
||||
evidence := truncateRunes(req.Subject, evidenceMaxChars)
|
||||
now := m.now()
|
||||
var resp ipc.IngestMailResp
|
||||
@@ -118,16 +186,21 @@ func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.Ing
|
||||
// something she read is a suggestion until he confirms it on /tasks.
|
||||
Status: store.TaskCandidate,
|
||||
}
|
||||
t.ExternalID = mailExternalID(source, req.UID, c.Text)
|
||||
if due, ok := email.ParseDue(c.Due); ok {
|
||||
t.Due = &due
|
||||
}
|
||||
id, created, err := m.st.CaptureTask(ctx, t)
|
||||
res, err := m.st.CaptureTask(ctx, t)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("mail intake: capture: %w", err)
|
||||
}
|
||||
resp.TaskIDs = append(resp.TaskIDs, id)
|
||||
if created {
|
||||
resp.TaskIDs = append(resp.TaskIDs, res.ID)
|
||||
if res.Created {
|
||||
resp.Created++
|
||||
// Only a row that was actually created. CaptureTask dedupes on
|
||||
// normalised text among live rows, so a mailbox re-read after a
|
||||
// restart must not refill the journal with tasks already in it.
|
||||
m.bus.Publish(publishableTask(t, now), now)
|
||||
}
|
||||
}
|
||||
// Counts only: the log line names the mailbox and the UID, never the subject,
|
||||
@@ -139,8 +212,8 @@ func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.Ing
|
||||
// wireMailIntake installs the IPC hook, or leaves it nil so the method reports
|
||||
// ErrUnknownMethod. Called on both startup paths (unlocked boot and passkey
|
||||
// unlock) so mail behaves the same either way.
|
||||
func wireMailIntake(srv *ipc.Server, st *store.Store, phr phraser.Phraser, cfg *config.Config) {
|
||||
mi := newMailIntake(st, phr, cfg)
|
||||
func wireMailIntake(srv *ipc.Server, st *store.Store, phr phraser.Phraser, cfg *config.Config, bus *event.Bus) {
|
||||
mi := newMailIntake(st, phr, cfg, bus)
|
||||
if mi == nil {
|
||||
return
|
||||
}
|
||||
@@ -155,3 +228,14 @@ func truncateRunes(s string, n int) string {
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
// mailExternalID names the message and the span a candidate was extracted
|
||||
// from. The mailbox and UID identify the message; the normalised text
|
||||
// identifies which of the candidates in it this is, so a message yielding two
|
||||
// tasks gets two keys and a re-read of it gets neither twice.
|
||||
//
|
||||
// UIDs are stable per mailbox, and a mailbox that renumbers (UIDVALIDITY
|
||||
// changing) re-proposes its tasks once, which is the safe direction.
|
||||
func mailExternalID(source string, uid uint32, text string) string {
|
||||
return fmt.Sprintf("%s#%d:%s", source, uid, store.NormalizeTaskText(text))
|
||||
}
|
||||
|
||||
+61
-2
@@ -171,12 +171,71 @@ func TestIngestTruncatesEvidence(t *testing.T) {
|
||||
// exist at all.
|
||||
func TestNewMailIntakeOffWithoutConfig(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
if mi := newMailIntake(st, nil, &config.Config{}); mi != nil {
|
||||
if mi := newMailIntake(st, nil, &config.Config{}, nil); mi != nil {
|
||||
t.Error("no email block must mean no mail intake")
|
||||
}
|
||||
// Configured but with a non-LLM phraser: still off — there is no fallback
|
||||
// extraction, by design.
|
||||
if mi := newMailIntake(st, nil, &config.Config{Email: &config.EmailConfig{}}); mi != nil {
|
||||
if mi := newMailIntake(st, nil, &config.Config{Email: &config.EmailConfig{}}, nil); mi != nil {
|
||||
t.Error("without a llama-server phraser there is nothing to extract with")
|
||||
}
|
||||
}
|
||||
|
||||
// The mailbox name becomes the provenance string, which is the vocabulary the
|
||||
// loop's rules trust. "email:" is not a source and neither is "email:anything
|
||||
// he could post at the socket".
|
||||
func TestIngestRejectsBadMailbox(t *testing.T) {
|
||||
for _, name := range []string{"", " ", "IN BOX", "IN\nBOX", "IN\x00BOX", strings.Repeat("щ", maxMailboxChars+1)} {
|
||||
mi, st, fake := newTestIntake(t, `[{"text":"дело","due":""}]`)
|
||||
req := ingestReq()
|
||||
req.Mailbox = name
|
||||
if _, err := mi.ingest(context.Background(), req); err == nil {
|
||||
t.Errorf("mailbox %q was accepted", name)
|
||||
}
|
||||
if fake.calls != 0 {
|
||||
t.Errorf("mailbox %q reached the model", name)
|
||||
}
|
||||
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
||||
t.Errorf("mailbox %q wrote %d tasks", name, len(tasks))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// slowLLM burns most of the extraction budget before answering, the way a
|
||||
// Thinking 1.7B does on a long mail.
|
||||
type slowLLM struct {
|
||||
reply string
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (s *slowLLM) Complete(ctx context.Context, _ llm.Req) (string, error) {
|
||||
select {
|
||||
case <-time.After(s.delay):
|
||||
return s.reply, nil
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// The extraction budget must not also bound the writes. It used to be one
|
||||
// context, so a model answering near the deadline lost the candidates it had
|
||||
// just produced.
|
||||
func TestIngestCapturesAfterASlowExtraction(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
mi := &mailIntake{
|
||||
st: st,
|
||||
ex: email.NewExtractor(&slowLLM{reply: `[{"text":"оплатить счёт","due":""}]`, delay: 90 * time.Millisecond}, 0, nil),
|
||||
timeout: 100 * time.Millisecond,
|
||||
now: func() time.Time { return time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) },
|
||||
}
|
||||
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if resp.Created != 1 {
|
||||
t.Fatalf("resp = %+v, want the candidate captured", resp)
|
||||
}
|
||||
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 1 {
|
||||
t.Errorf("got %d tasks, want 1", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
+203
-39
@@ -25,7 +25,7 @@
|
||||
// When a passkey credential is enrolled AND no env key is set, the daemon
|
||||
// starts in LOCKED mode: the IPC server runs but rejects all store methods
|
||||
// except MethodAssertStepUp and MethodUnlock. A passkey assertion followed
|
||||
// by MethodUnlock (with the same credential's public key) unwraps the at-rest
|
||||
// by MethodUnlock (with that credential's WebAuthn PRF output) unwraps the at-rest
|
||||
// AES-256 key from a wrapped blob on disk (HKDF-SHA256 + AES-GCM) and opens
|
||||
// the encrypted store. After unlock, the daemon wires voice, loop, and
|
||||
// delivery and runs normally.
|
||||
@@ -34,10 +34,13 @@
|
||||
// starts unlocked from the env key (pre-unlock behavior). Enrolling a passkey
|
||||
// while unlocked calls MethodStoreEncryptionKey to wrap the env key and
|
||||
// persist the wrapped blob — enabling cold-start unlock on the next boot
|
||||
// after the env key is removed.
|
||||
// after the env key is removed. That write happens once, when no blob
|
||||
// exists; replacing an existing one takes an explicit request, see
|
||||
// cmd/mavend/keyfile.go.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -48,6 +51,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -66,12 +70,19 @@ import (
|
||||
|
||||
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
|
||||
|
||||
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode.
|
||||
// In locked mode, all CoreAPI methods return errLocked. The unlock path
|
||||
// replaces the CoreAPI with the real store adapter and flips the flag.
|
||||
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode, and
|
||||
// owns the store handle the unlock path creates.
|
||||
//
|
||||
// The store matters here because of who runs when. In locked mode there is no
|
||||
// store at boot; one is opened inside UnlockFn, on an IPC goroutine, minutes
|
||||
// or days later. Shutdown runs on the main goroutine. Without a handoff the
|
||||
// main goroutine has nothing to close, and store.Close is what re-encrypts
|
||||
// the tmpfs working copy back over the ciphertext file — so a daemon that
|
||||
// cold-started lost every write of that session, silently, on the next boot.
|
||||
type daemonLock struct {
|
||||
mu sync.Mutex
|
||||
locked bool
|
||||
st *store.Store
|
||||
}
|
||||
|
||||
func newDaemonLock(locked bool) *daemonLock {
|
||||
@@ -84,10 +95,25 @@ func (l *daemonLock) isLocked() bool {
|
||||
return l.locked
|
||||
}
|
||||
|
||||
func (l *daemonLock) unlock() {
|
||||
// unlock flips the flag and takes ownership of the store opened by UnlockFn.
|
||||
func (l *daemonLock) unlock(st *store.Store) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.locked = false
|
||||
l.st = st
|
||||
}
|
||||
|
||||
// closeStore seals the store the unlock path opened, if any. Safe to call
|
||||
// when the daemon never unlocked, and safe to call twice.
|
||||
func (l *daemonLock) closeStore() error {
|
||||
l.mu.Lock()
|
||||
st := l.st
|
||||
l.st = nil
|
||||
l.mu.Unlock()
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
return st.Close()
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -100,7 +126,7 @@ func main() {
|
||||
func run(args []string) error {
|
||||
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
||||
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
||||
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap)")
|
||||
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap; the daemon does not answer until it finishes)")
|
||||
flag.CommandLine.Parse(args)
|
||||
reembedOnStart = *reembed
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
@@ -142,11 +168,28 @@ func run(args []string) error {
|
||||
var st *store.Store
|
||||
var envKeyBytes []byte // kept for WrapKeyFn (enrollment wraps this key)
|
||||
|
||||
// dbKey — the plaintext at-rest key, once the daemon has one. Set at boot
|
||||
// in env-key mode and inside UnlockFn after a cold start. WrapKeyFn reads
|
||||
// it from an IPC goroutine, hence the atomic: srv's function fields are
|
||||
// installed before Serve and must not be reassigned afterwards.
|
||||
var dbKey atomic.Pointer[[]byte]
|
||||
|
||||
// wrappedPath resolves the blob location the same way for both the read
|
||||
// at boot and every write, so a default-path deployment cannot wrap to
|
||||
// one file and unwrap from another.
|
||||
wrappedPath := func() string {
|
||||
if *wrappedKeyPath != "" {
|
||||
return *wrappedKeyPath
|
||||
}
|
||||
return cfg.DefaultWrappedKeyPath()
|
||||
}
|
||||
|
||||
if !locked {
|
||||
// Normal boot: env key or plaintext (dev/CI)
|
||||
if envKey != nil {
|
||||
envKeyBytes = make([]byte, len(envKey))
|
||||
copy(envKeyBytes, envKey)
|
||||
dbKey.Store(&envKeyBytes)
|
||||
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, envKey)
|
||||
} else {
|
||||
st, err = store.Open(ctx, cfg.DBPath)
|
||||
@@ -155,6 +198,14 @@ func run(args []string) error {
|
||||
return fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
defer st.Close()
|
||||
} else {
|
||||
// Locked boot: the store does not exist yet. Seal whatever UnlockFn
|
||||
// opened, at shutdown, on this goroutine.
|
||||
defer func() {
|
||||
if err := dl.closeStore(); err != nil {
|
||||
log.Printf("mavend: seal store on shutdown: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ----- daemon components (only wired when unlocked) -----
|
||||
@@ -174,6 +225,16 @@ func run(args []string) error {
|
||||
crawlWkr *crawlWorker // nil ⇒ no page is watched (the default)
|
||||
)
|
||||
|
||||
// The unified intake journal (Vikunja #283). Built before anything else
|
||||
// that holds a CoreAPI, because intakeAPI wraps that one interface and
|
||||
// every intake path in the daemon reaches its sink through it. nil (the
|
||||
// operator set intake_journal negative) means no decorator at all.
|
||||
evBus := newEventBus(cfg)
|
||||
// coreFor is what every in-process holder of a CoreAPI now takes, instead
|
||||
// of a bare ipc.NewStoreAPI(st). Identical behaviour plus one published
|
||||
// envelope per successful intake write.
|
||||
coreFor := func() ipc.CoreAPI { return newIntakeAPI(ipc.NewStoreAPI(st), evBus, time.Now) }
|
||||
|
||||
if !locked {
|
||||
rules = loop.DefaultRules()
|
||||
gatherer = loop.NewGatherer(st, rules)
|
||||
@@ -217,7 +278,7 @@ func run(args []string) error {
|
||||
eco = wireEcosystem(cfg)
|
||||
|
||||
// voice
|
||||
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory(), st, eco)
|
||||
voiceW, err = wireVoice(cfg, coreFor(), phr, st.VectorMemory(), st, eco)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire voice: %w", err)
|
||||
}
|
||||
@@ -267,19 +328,23 @@ func run(args []string) error {
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
feedWkr = newFeedWorker(ipc.NewStoreAPI(st), embedderOf(voiceW), cfg)
|
||||
crawlWkr = newCrawlWorker(newCrawler(cfg), ipc.NewStoreAPI(st), embedderOf(voiceW), cfg)
|
||||
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
||||
|
||||
coreAPI = &daemonAPI{
|
||||
CoreAPI: ipc.NewStoreAPI(st),
|
||||
CoreAPI: coreFor(),
|
||||
getTrace: tl.trace,
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
getEvents: intakeEventsFn(evBus),
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
api := coreAPI.(*daemonAPI)
|
||||
api.chatFn = voiceW.handler.handleText
|
||||
}
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
coreAPI.(*daemonAPI).getMCPServers = voiceW.mcp.status
|
||||
}
|
||||
} else {
|
||||
// locked mode: no real store yet, so there's no meaningful CoreAPI to
|
||||
// serve. srv.Check below is the actual guard — every CoreAPI call is
|
||||
@@ -312,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
|
||||
@@ -323,50 +392,115 @@ func run(args []string) error {
|
||||
}
|
||||
|
||||
srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) }
|
||||
srv.LockedFn = dl.isLocked
|
||||
|
||||
// wg is declared here rather than next to srv.Serve because the media
|
||||
// retention loop starts on this path too, and shutdown has to wait for a
|
||||
// prune in flight: it deletes files.
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// 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
|
||||
// ipc.MethodIngestMail reports ErrUnknownMethod.
|
||||
if !locked {
|
||||
wireMailIntake(srv, st, phr, cfg)
|
||||
wireMailIntake(srv, st, phr, cfg, evBus)
|
||||
wireModelSwap(srv, phr, cfg)
|
||||
// Vision + the media blob store (Vikunja #252). Both stay dark without a
|
||||
// media block; MethodDescribeImage answers ErrUnknownMethod then.
|
||||
keeper := wireVision(ctx, &wg, srv, st, embedderOf(voiceW), cfg)
|
||||
// The meeting recorder (Vikunja #253) shares that blob store and its
|
||||
// retention loop. Off unless a capture block enables it, in which case
|
||||
// all four capture methods answer ErrUnknownMethod.
|
||||
wireCapture(ctx, &wg, srv, keeper, st, voiceW, phr, cfg)
|
||||
// Voice identification (Vikunja #255). Enrolment plumbing only until a
|
||||
// speaker-embedding model exists on disk; off entirely without a speaker
|
||||
// block, so no wire path takes a voiceprint on a default box.
|
||||
wireSpeaker(srv, st, cfg)
|
||||
}
|
||||
|
||||
// WrapKeyFn — wraps the env key with a passkey credential public key and
|
||||
// persists the wrapped blob. Only wired when the daemon has the key in
|
||||
// memory (env key mode). Called by mavweb after passkey enrollment.
|
||||
if envKeyBytes != nil {
|
||||
srv.WrapKeyFn = func(ctx context.Context, publicKey []byte) error {
|
||||
blob, err := webauthn.WrapKey(envKeyBytes, publicKey)
|
||||
// WrapKeyFn — wraps the at-rest key under the passkey PRF secret and
|
||||
// persists the wrapped blob. Called by mavweb after every assertion.
|
||||
//
|
||||
// It is wired in locked mode too, not only in env-key mode, and that is
|
||||
// what makes a v1 blob recoverable. A box enrolled before Vikunja #14
|
||||
// cold-starts through the legacy public-key retry in mavweb, and the
|
||||
// StoreEncryptionKey that follows rewrites the blob as v2. Without this
|
||||
// the only escape from a v1 blob was putting MAVEN_DB_KEY back in the
|
||||
// environment, which is the thing cold-start unlock exists to avoid.
|
||||
//
|
||||
// webauthn.WrapKey refuses anything that is not a 32-byte PRF output, so
|
||||
// an authenticator without PRF support produces no wrapped file at all
|
||||
// rather than a file that looks protected and is not.
|
||||
if envKeyBytes != nil || locked {
|
||||
srv.WrapKeyFn = func(ctx context.Context, secret []byte, explicit bool) error {
|
||||
kp := dbKey.Load()
|
||||
if kp == nil {
|
||||
return errors.New("wrap encryption key: the daemon is locked and has no key yet (unlock first)")
|
||||
}
|
||||
wp := wrappedPath()
|
||||
// Asserting a passkey is not a request to rewrite the cold-start
|
||||
// key. Without this an assertion carrying a substituted PRF value
|
||||
// re-wrapped the real database key under it, and a second
|
||||
// authenticator silently replaced the first one's blob.
|
||||
if !explicit {
|
||||
if _, err := os.Stat(wp); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("check wrapped key: %w", err)
|
||||
}
|
||||
}
|
||||
wrote, err := wrapKeyToFile(wp, *kp, secret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wrap encryption key: %w", err)
|
||||
return err
|
||||
}
|
||||
wp := *wrappedKeyPath
|
||||
if wp == "" {
|
||||
wp = cfg.DefaultWrappedKeyPath()
|
||||
if wrote {
|
||||
log.Printf("mavend: wrapped encryption key under this passkey's PRF output → %s", wp)
|
||||
}
|
||||
if err := os.WriteFile(wp, blob, 0o600); err != nil {
|
||||
return fmt.Errorf("write wrapped key: %w", err)
|
||||
}
|
||||
log.Printf("mavend: wrapped encryption key with passkey credential (%d bytes)", len(blob))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// UnlockFn — cold-start unlock: unwraps the encryption key from the wrapped
|
||||
// blob using the passkey credential public key, opens the store, wires all
|
||||
// UnlockFn — cold-start unlock: unwraps the encryption key from the
|
||||
// wrapped blob using the passkey PRF secret, opens the store, wires all
|
||||
// daemon components, and replaces the locked API.
|
||||
if locked {
|
||||
srv.UnlockFn = func(ctx context.Context, publicKey []byte) error {
|
||||
wp := *wrappedKeyPath
|
||||
var unlockMu sync.Mutex
|
||||
srv.UnlockFn = func(ctx context.Context, secret []byte) error {
|
||||
// One unlock at a time, and never a second one. Without this a
|
||||
// concurrent pair of Unlock calls would each open a store and
|
||||
// wire a full daemon, and the loser's goroutines would run
|
||||
// against a store nobody closes.
|
||||
unlockMu.Lock()
|
||||
defer unlockMu.Unlock()
|
||||
if !dl.isLocked() {
|
||||
return nil // already unlocked; the caller does not need to know
|
||||
}
|
||||
|
||||
// Depth, not a boundary. MethodAssertStepUp is AuthRead, so
|
||||
// anything that can open the same-uid socket can flip the
|
||||
// session and reach MethodUnlock. What actually stops a local
|
||||
// attacker is the 32-byte PRF output they do not have, and that
|
||||
// was true before this check. What this check stops is an
|
||||
// accidental unlock attempt from an unrelated local caller.
|
||||
if !passkeySess.IsStepUp() {
|
||||
return errors.New("unlock: no verified passkey assertion (assert first)")
|
||||
}
|
||||
|
||||
wp := wrappedPath()
|
||||
blob, err := os.ReadFile(wp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read wrapped key: %w", err)
|
||||
}
|
||||
key, err := webauthn.UnwrapKey(blob, publicKey)
|
||||
key, version, err := webauthn.UnwrapKey(blob, secret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unwrap key: %w", err)
|
||||
}
|
||||
if version == webauthn.BlobV1 {
|
||||
log.Printf("SECURITY: %s was unwrapped from a %s blob. The wrapping key is derived from the credential PUBLIC key, which mavweb also writes to its passkeys.json — anyone holding both files can recover the database key with no authenticator. Use the \"rewrite cold-start key\" button on /auth/webauthn with a PRF-capable authenticator to replace it with a v2 blob.", wp, version)
|
||||
}
|
||||
// WrapKeyFn needs the key to be able to rewrite the blob later.
|
||||
keyCopy := bytes.Clone(key)
|
||||
dbKey.Store(&keyCopy)
|
||||
// Open the store with the unwrapped key.
|
||||
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key)
|
||||
if err != nil {
|
||||
@@ -412,7 +546,7 @@ func run(args []string) error {
|
||||
|
||||
eco = wireEcosystem(cfg)
|
||||
|
||||
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory(), st, eco)
|
||||
voiceW, err = wireVoice(cfg, coreFor(), phr, st.VectorMemory(), st, eco)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire voice: %w", err)
|
||||
}
|
||||
@@ -456,23 +590,30 @@ func run(args []string) error {
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
feedWkr = newFeedWorker(ipc.NewStoreAPI(st), embedderOf(voiceW), cfg)
|
||||
crawlWkr = newCrawlWorker(newCrawler(cfg), ipc.NewStoreAPI(st), embedderOf(voiceW), cfg)
|
||||
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
||||
|
||||
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
||||
newAPI := &daemonAPI{
|
||||
CoreAPI: ipc.NewStoreAPI(st),
|
||||
CoreAPI: coreFor(),
|
||||
getTrace: tl.trace,
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
getEvents: intakeEventsFn(evBus),
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
newAPI.chatFn = voiceW.handler.handleText
|
||||
}
|
||||
srv.SetAPI(newAPI)
|
||||
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
||||
wireMailIntake(srv, st, phr, cfg)
|
||||
wireMailIntake(srv, st, phr, cfg, evBus)
|
||||
wireModelSwap(srv, phr, cfg)
|
||||
keeper := wireVision(ctx, &wg, srv, st, embedderOf(voiceW), cfg)
|
||||
wireCapture(ctx, &wg, srv, keeper, st, voiceW, phr, cfg)
|
||||
// Voice identification (Vikunja #255). Enrolment plumbing only until a
|
||||
// speaker-embedding model exists on disk; off entirely without a speaker
|
||||
// block, so no wire path takes a voiceprint on a default box.
|
||||
wireSpeaker(srv, st, cfg)
|
||||
|
||||
// Start voice server.
|
||||
if voiceW != nil {
|
||||
@@ -518,13 +659,22 @@ func run(args []string) error {
|
||||
}()
|
||||
}
|
||||
|
||||
dl.unlock()
|
||||
// Keep MCP connections alive (nil unless configured).
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
go voiceW.mcp.run(ctx)
|
||||
}
|
||||
|
||||
// Re-enumerate the house for new devices (nil unless configured).
|
||||
if voiceW != nil && voiceW.home != nil {
|
||||
go voiceW.home.run(ctx)
|
||||
}
|
||||
|
||||
dl.unlock(st)
|
||||
log.Printf("mavend: unlocked via passkey assertion")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
@@ -577,6 +727,20 @@ func run(args []string) error {
|
||||
crawlWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
voiceW.mcp.run(ctx)
|
||||
}()
|
||||
}
|
||||
if voiceW != nil && voiceW.home != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
voiceW.home.run(ctx)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
// mcpRefreshInterval — how often the manager is asked to re-dial servers that
|
||||
// are down. It is a tick, not a retry rate: mcp.Manager holds a per-server
|
||||
// backoff that starts at DefaultReconnectEvery and doubles to
|
||||
// MaxReconnectEvery, so a permanently misconfigured stdio server is not
|
||||
// re-exec'd once a minute forever.
|
||||
const mcpRefreshInterval = time.Minute
|
||||
|
||||
// mcpWiring — the MCP client, when the `mcp` block configures at least one
|
||||
// enabled server. nil ⇒ nothing was configured, nothing is connected, and an
|
||||
// allowlist row that happens to look like an MCP row refuses to run.
|
||||
//
|
||||
// It lives on the voice wiring because MCP tools ARE acts: they run through
|
||||
// tool.Executor, the enabled allowlist and the confirm turn, which only exist
|
||||
// on the voice/chat path. No voice surface ⇒ nothing that could call a tool.
|
||||
type mcpWiring struct {
|
||||
mgr *mcp.Manager
|
||||
st *store.Store
|
||||
}
|
||||
|
||||
// wireMCP builds the manager. It does NOT dial: run does that, on its own
|
||||
// goroutine, which is what makes "Maven starting is not contingent on someone
|
||||
// else's process" true rather than merely intended.
|
||||
//
|
||||
// Dialing here used to be synchronous with a 30s budget, from wireVoice, from
|
||||
// run. Connect dials serially and each HTTP dial is three requests against
|
||||
// that server's timeout, so one black-holed endpoint cost 15s of boot and two
|
||||
// cost the whole budget. On the passkey path wireVoice runs inside the unlock
|
||||
// handler, so it delayed the answer to an unlock as well. Not failing and not
|
||||
// blocking are different properties and only the first one held.
|
||||
func wireMCP(cfg *config.Config, st *store.Store) *mcpWiring {
|
||||
servers := cfg.MCPServers()
|
||||
if len(servers) == 0 {
|
||||
return nil
|
||||
}
|
||||
limits := webfetch.Config{}
|
||||
if cfg.MCP != nil {
|
||||
limits.AllowHosts = cfg.MCP.AllowHosts
|
||||
limits.DenyHosts = cfg.MCP.DenyHosts
|
||||
limits.MaxBytes = cfg.MCP.MaxBytes
|
||||
limits.Timeout = time.Duration(cfg.MCP.Timeout)
|
||||
limits.HostInterval = time.Duration(cfg.MCP.HostInterval)
|
||||
}
|
||||
mgr, err := mcp.NewManager(mcp.WebfetchDoor(limits), servers)
|
||||
if err != nil {
|
||||
// Validation already ran in config.validate, so this is a programming
|
||||
// error rather than a config one. Still not fatal: MCP off is a working
|
||||
// Maven.
|
||||
log.Printf("mcp: not wired: %v", err)
|
||||
return nil
|
||||
}
|
||||
return &mcpWiring{mgr: mgr, st: st}
|
||||
}
|
||||
|
||||
// connect dials every server and reconciles what came back. Called from run,
|
||||
// under the daemon's context, so a shutdown during a slow dial is observed.
|
||||
func (w *mcpWiring) connect(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mgr.Connect(ctx)
|
||||
w.propose(ctx)
|
||||
}
|
||||
|
||||
// propose writes a 'proposed' allowlist row for every discovered tool, and
|
||||
// reconciles the rows that already exist against what the server offers today.
|
||||
// It does NOT enable anything: a configured server is a place Maven may look,
|
||||
// not a capability she has. Kami enables what he wants on /tools, behind
|
||||
// step-up, which is the same gate a shell tool goes through.
|
||||
//
|
||||
// Three things happen per discovered tool.
|
||||
//
|
||||
// A name not in the store becomes a proposal, carrying the tool's fingerprint.
|
||||
//
|
||||
// A name already in the store is reconciled against that fingerprint. A tool
|
||||
// whose description, schema or readOnlyHint changed since it was approved drops
|
||||
// back to 'proposed' and, if it stopped claiming read-only, to destructive=1.
|
||||
// Insert-or-skip was not enough on its own: the cmd is a late-bound reference
|
||||
// to a name the far end owns, so the server can redefine list_tasks into
|
||||
// something that writes without the row changing at all.
|
||||
//
|
||||
// A row whose server is connected and no longer offers the tool is withdrawn.
|
||||
func (w *mcpWiring) propose(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
fresh, changed := 0, 0
|
||||
seen := map[string]string{} // local name → "server/tool", for collisions
|
||||
for _, t := range w.mgr.Tools() {
|
||||
name := mcp.LocalName(t.Server, t.Name)
|
||||
remote := t.Server + "/" + t.Name
|
||||
// Two different tools can flatten to one local name: server "vik" with
|
||||
// tool "list_tasks" and server "vik_list" with tool "tasks" both give
|
||||
// "vik_list_tasks". The store keys rows by name, so the second would
|
||||
// land on the first one's row. Config-controlled and therefore rare,
|
||||
// but silently reusing a row is the wrong way to lose that race.
|
||||
if prev, dup := seen[name]; dup {
|
||||
log.Printf("mcp: %s and %s both map to the allowlist name %q — skipping the second, rename a server",
|
||||
prev, remote, name)
|
||||
continue
|
||||
}
|
||||
seen[name] = remote
|
||||
// No readOnlyHint ⇒ assume it mutates ⇒ the confirm turn. Being wrong
|
||||
// in this direction only costs a question.
|
||||
destructive := !t.ReadOnly
|
||||
provenance := fmt.Sprintf("mcp %s/%s", t.Server, t.Name)
|
||||
if t.Description != "" {
|
||||
provenance += ": " + t.Description
|
||||
}
|
||||
fp := mcp.Fingerprint(t)
|
||||
ok, err := w.st.ProposeMCPTool(ctx, name, mcp.Scope(t.Server),
|
||||
mcp.Cmd(t.Server, t.Name), destructive, provenance, fp, now)
|
||||
if err != nil {
|
||||
log.Printf("mcp: propose %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
fresh++
|
||||
continue
|
||||
}
|
||||
// The row already existed. Its provenance is whatever the server said
|
||||
// the first time; reconciling rewrites it, so what /tools shows is what
|
||||
// the server says now.
|
||||
ch, err := w.st.ReconcileMCPTool(ctx, name, fp, destructive, provenance, now)
|
||||
if err != nil {
|
||||
log.Printf("mcp: reconcile %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
if !ch.Changed {
|
||||
continue
|
||||
}
|
||||
changed++
|
||||
switch {
|
||||
case ch.Demoted && ch.Escalated:
|
||||
log.Printf("mcp: %s changed on the server and no longer claims read-only — disabled and marked destructive, re-approve it on /tools", name)
|
||||
case ch.Demoted:
|
||||
log.Printf("mcp: %s changed on the server since it was enabled — disabled, re-approve it on /tools", name)
|
||||
default:
|
||||
log.Printf("mcp: %s changed on the server; the proposal now shows the new description", name)
|
||||
}
|
||||
}
|
||||
w.withdrawGone(ctx, seen, now)
|
||||
if fresh > 0 {
|
||||
log.Printf("mcp: %d new tool proposal(s) waiting on /tools", fresh)
|
||||
}
|
||||
if changed > 0 {
|
||||
log.Printf("mcp: %d tool(s) changed since approval and need another look", changed)
|
||||
}
|
||||
}
|
||||
|
||||
// withdrawGone disarms rows whose tool the server stopped offering. Only
|
||||
// servers that are CONNECTED are considered: a tool missing because its server
|
||||
// is down is not a tool that was withdrawn, and disabling a capability every
|
||||
// time a process restarts would be worse than the problem.
|
||||
func (w *mcpWiring) withdrawGone(ctx context.Context, seen map[string]string, now time.Time) {
|
||||
live := map[string]bool{}
|
||||
for _, name := range w.mgr.Connected() {
|
||||
live[name] = true
|
||||
}
|
||||
if len(live) == 0 {
|
||||
return
|
||||
}
|
||||
rows, err := w.st.ListTools(ctx, "")
|
||||
if err != nil {
|
||||
log.Printf("mcp: list tools: %v", err)
|
||||
return
|
||||
}
|
||||
for _, row := range rows {
|
||||
server, remote, ok := mcp.ParseCmd(row.Cmd)
|
||||
if !ok || !live[server] {
|
||||
continue
|
||||
}
|
||||
if _, still := seen[row.Name]; still {
|
||||
continue
|
||||
}
|
||||
note := fmt.Sprintf("mcp %s/%s: no longer offered by the server", server, remote)
|
||||
wasEnabled, err := w.st.WithdrawTool(ctx, row.Name, note, now)
|
||||
if err != nil {
|
||||
log.Printf("mcp: withdraw %s: %v", row.Name, err)
|
||||
continue
|
||||
}
|
||||
if wasEnabled {
|
||||
log.Printf("mcp: %s was enabled but %s no longer offers it — disabled", row.Name, server)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// run re-dials downed servers and picks up tools that appeared, until ctx is
|
||||
// canceled.
|
||||
func (w *mcpWiring) run(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
// The first dial happens here rather than at wiring time, so boot never
|
||||
// waits on someone else's process.
|
||||
w.connect(ctx)
|
||||
t := time.NewTicker(mcpRefreshInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
w.mgr.Refresh(ctx)
|
||||
w.propose(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// status maps the manager's view onto the wire type the web surface reads.
|
||||
func (w *mcpWiring) status() []ipc.MCPServerStatus {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
in := w.mgr.Status()
|
||||
out := make([]ipc.MCPServerStatus, 0, len(in))
|
||||
for _, s := range in {
|
||||
out = append(out, ipc.MCPServerStatus{
|
||||
Name: s.Name,
|
||||
Transport: s.Transport,
|
||||
Target: s.Target,
|
||||
Connected: s.Connected,
|
||||
Server: s.Server,
|
||||
Tools: s.Tools,
|
||||
Err: s.Err,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (w *mcpWiring) close() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
_ = w.mgr.Close()
|
||||
}
|
||||
|
||||
// caller is the tool.MCPCaller the executor gets, or nil when MCP is off.
|
||||
func (w *mcpWiring) caller() *mcp.Manager {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.mgr
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
)
|
||||
|
||||
func TestWireMCPOffWhenUnconfigured(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
for name, cfg := range map[string]*config.Config{
|
||||
"no block": {},
|
||||
"nothing enabled": {MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{
|
||||
{Name: "vikunja", URL: "http://192.168.1.104:9100/mcp"},
|
||||
}}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if w := wireMCP(cfg, st); w != nil {
|
||||
t.Fatal("MCP must be off unless a server is configured AND enabled")
|
||||
}
|
||||
})
|
||||
}
|
||||
// nil wiring must be safe to use everywhere it is reachable.
|
||||
var w *mcpWiring
|
||||
w.close()
|
||||
w.propose(context.Background())
|
||||
if w.status() != nil || w.caller() != nil {
|
||||
t.Fatal("a nil wiring must report nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// Wiring must not dial. Boot used to block for the whole per-server timeout
|
||||
// budget on a black-holed endpoint, and on the passkey path that delay landed
|
||||
// inside the unlock handler.
|
||||
func TestWireMCPDoesNotDial(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
|
||||
Name: "dead", Command: "/nonexistent/mcp-server", Enabled: true,
|
||||
}}}}, st)
|
||||
if w == nil {
|
||||
t.Fatal("a configured server should wire")
|
||||
}
|
||||
defer w.close()
|
||||
if s := w.status(); len(s) != 1 || s[0].Err != "" {
|
||||
t.Fatalf("wireMCP dialled: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// An unreachable server must not stop the daemon, must be reported as down, and
|
||||
// must propose nothing.
|
||||
func TestWireMCPUnreachableServerIsNotFatal(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
|
||||
Name: "dead", Command: "/nonexistent/mcp-server", Enabled: true,
|
||||
}}}}, st)
|
||||
if w == nil {
|
||||
t.Fatal("a configured server should still wire")
|
||||
}
|
||||
defer w.close()
|
||||
w.connect(context.Background())
|
||||
st2 := w.status()
|
||||
if len(st2) != 1 || st2[0].Connected || st2[0].Err == "" {
|
||||
t.Fatalf("status = %+v", st2)
|
||||
}
|
||||
tools, err := st.ListTools(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tools) != 0 {
|
||||
t.Fatalf("a server that never answered must propose nothing, got %+v", tools)
|
||||
}
|
||||
}
|
||||
|
||||
// A url server whose address is private is refused by webfetch unless that
|
||||
// server sets allow_private. This is the guard the whole MCP path rides on, so
|
||||
// it is asserted here too, at the wiring level.
|
||||
func TestWireMCPPrivateURLRefusedWithoutAllowPrivate(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
|
||||
Name: "lan", URL: "http://127.0.0.1:9100/mcp", Enabled: true,
|
||||
}}}}, st)
|
||||
if w == nil {
|
||||
t.Fatal("should wire")
|
||||
}
|
||||
defer w.close()
|
||||
w.connect(context.Background())
|
||||
s := w.status()[0]
|
||||
if s.Connected {
|
||||
t.Fatal("a loopback server must not connect without allow_private")
|
||||
}
|
||||
if !strings.Contains(s.Err, "private address") {
|
||||
t.Fatalf("err = %q, want the private-address refusal", s.Err)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,20 @@ import (
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// memoryEvalTimeout — the per-request deadline on one evaluation.
|
||||
//
|
||||
// It used to be five minutes, on the grounds that nobody waits for the answer.
|
||||
// Nobody waits for the evaluation, but there is ONE resident model behind one
|
||||
// llama-server, so a voice turn arriving mid-evaluation waited behind it: five
|
||||
// minutes of evaluation was five minutes of a mute assistant.
|
||||
//
|
||||
// The background client now yields the slot while a turn is in flight, so the
|
||||
// collision is handled where it belongs and this is a prompt budget again.
|
||||
// Sixty seconds is long enough for a Thinking model here, and an evaluation cut
|
||||
// off costs nothing, because it is retried at the next interval. Raise it if
|
||||
// observations start truncating.
|
||||
const memoryEvalTimeout = 60 * time.Second
|
||||
|
||||
// memoryEvalWorker — ticker + evaluator.
|
||||
type memoryEvalWorker struct {
|
||||
eval *memeval.Evaluator
|
||||
@@ -47,9 +61,9 @@ func newMemoryEvalWorker(st *store.Store, phr phraser.Phraser, cfg *config.Confi
|
||||
if interval <= 0 {
|
||||
interval = config.DefaultMemoryEvalInterval
|
||||
}
|
||||
// A generous per-request timeout: this is a long prompt to a Thinking model
|
||||
// and nobody is waiting on the answer.
|
||||
client := llmClientFor(lp, 5*time.Minute)
|
||||
// Background: nobody is waiting on an observation, and it must not sit in
|
||||
// front of a voice turn on the single llama-server slot.
|
||||
client := llmBackgroundClientFor(lp, memoryEvalTimeout)
|
||||
ev := memeval.NewEvaluator(st, st, client, memeval.Config{
|
||||
MaxItems: cfg.MemoryEval.MaxItems,
|
||||
MinConfidence: cfg.MemoryEval.MinConfidence,
|
||||
|
||||
@@ -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,8 +107,43 @@ 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
|
||||
}
|
||||
|
||||
// backgroundQuiet — how long background work stays off the resident model after
|
||||
// a foreground request. Long enough to cover the gap between the router call and
|
||||
// the phraser call of one turn (router p50 is ~2.7s on this box), short enough
|
||||
// that a quiet mailbox is still read promptly.
|
||||
const backgroundQuiet = 10 * time.Second
|
||||
|
||||
// residentGate — the priority gate on the one llama-server slot, shared by every
|
||||
// client llmClientFor builds. Package level because the daemon owns exactly one
|
||||
// llama-server: two gates would be two opinions about one queue.
|
||||
//
|
||||
// The problem it solves: llama-server runs a single slot, so requests queue. Mail
|
||||
// extraction is allowed two minutes, and a first poll can hand core 25 messages
|
||||
// back to back. Without a gate a voice turn arriving mid-extraction waits for
|
||||
// whatever is left of that budget, the router times out into the classifier
|
||||
// cascade at its 36.8% floor, and the phraser just waits.
|
||||
var residentGate = llm.NewGate(backgroundQuiet)
|
||||
|
||||
// llmBackgroundClientFor is llmClientFor for work nobody is waiting on: mail
|
||||
// extraction and memory evaluation. Same swap-following client, but it yields
|
||||
// to voice turns and only one such request runs at a time.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/netscan"
|
||||
)
|
||||
|
||||
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
||||
// longer than this has already failed as a turn, so the scan returns whatever
|
||||
// it found rather than keeping him waiting.
|
||||
//
|
||||
// It has to be consistent with the shipped defaults or every scan is truncated:
|
||||
// a /24 at four ports is 1016 probes, which at netscan.DefaultRate of 100 a
|
||||
// second is a little over ten seconds plus the tail dials. 30s leaves room for
|
||||
// that without pretending a slower rate would fit.
|
||||
const scanBudget = 30 * time.Second
|
||||
|
||||
// scanCacheTTL — how long a scan answer is reused. Two questions in a row used
|
||||
// to be two full sweeps of the LAN, up to a thousand connections each. The
|
||||
// network does not change on the scale of a follow-up question, and the cheapest
|
||||
// packet is the one not sent.
|
||||
const scanCacheTTL = 2 * time.Minute
|
||||
|
||||
// scanReadOut — how many hosts go into the written record's first lines before
|
||||
// it says "и ещё N". Nothing reads addresses out loud; see scanSummary.
|
||||
const scanReadOut = 20
|
||||
|
||||
// netWiring — the LAN scanner, when the `netscan` block is enabled. nil ⇒ Maven
|
||||
// never puts a discovery packet on the network.
|
||||
//
|
||||
// Unlike the house, a scan is a READ, so it is a query source rather than an
|
||||
// act: there is no allowlist row and no confirm turn, because nothing changes.
|
||||
// What makes that safe is that the range is not an argument — see
|
||||
// internal/netscan's package comment.
|
||||
type netWiring struct {
|
||||
scanner *netscan.Scanner
|
||||
subnets []string
|
||||
// api — where the address list is WRITTEN. The spoken answer is a count
|
||||
// and a shape, so the detail has to land somewhere readable; a note under
|
||||
// source "scan:lan" puts it on /history and, through the intake decorator,
|
||||
// on /events. It is also the only record that Maven put packets on the LAN
|
||||
// at all. nil ⇒ nothing is written, which is what the tests use.
|
||||
api ipc.CoreAPI
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
cached netscan.Result
|
||||
cachedAt time.Time
|
||||
}
|
||||
|
||||
// wireNetScan builds the scanner. nil unless the block is enabled and valid.
|
||||
func wireNetScan(cfg *config.Config, api ipc.CoreAPI) *netWiring {
|
||||
nc, ok := cfg.NetScanner()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := netscan.Validate(nc); err != nil {
|
||||
// config.validate already ran this, so reaching here is a programming
|
||||
// error rather than a config one. Not fatal: the scanner off is a
|
||||
// working Maven.
|
||||
log.Printf("netscan: not wired: %v", err)
|
||||
return nil
|
||||
}
|
||||
return &netWiring{scanner: netscan.New(nc), subnets: nc.Subnets, api: api, now: time.Now}
|
||||
}
|
||||
|
||||
// scan runs a scan, or reuses one younger than scanCacheTTL.
|
||||
func (w *netWiring) scan(ctx context.Context) (netscan.Result, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
now := w.now()
|
||||
if !w.cachedAt.IsZero() && now.Sub(w.cachedAt) < scanCacheTTL {
|
||||
return w.cached, nil
|
||||
}
|
||||
scanCtx, cancel := context.WithTimeout(ctx, scanBudget)
|
||||
defer cancel()
|
||||
res, err := w.scanner.Scan(scanCtx)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
w.cached, w.cachedAt = res, now
|
||||
// Written on a fresh scan only: the record is a trace of packets going out,
|
||||
// so a cached answer must not forge a second one.
|
||||
w.writeScanRecord(ctx, res)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// scanSummary answers "какие устройства в сети?" in one spoken line.
|
||||
//
|
||||
// It does NOT read addresses out. This is the query path, so the reply goes to
|
||||
// piper as well as to /chat, and "192.168.1.1 (80, 443); 192.168.1.14 (22)" is
|
||||
// a digit stream nobody can follow through a speaker. She says how many and
|
||||
// what shape they are; the addresses go into a note (see writeScanRecord).
|
||||
func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
if w == nil {
|
||||
return "", false
|
||||
}
|
||||
res, err := w.scan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("netscan: scan: %v", err)
|
||||
return "не получилось просканировать сеть.", true
|
||||
}
|
||||
// A truncated run is not a statement about the LAN. Saying "нашла 6
|
||||
// устройств" after stopping two thirds of the way through the range is a
|
||||
// false claim, and the addresses at the end are the ones that go missing.
|
||||
tail := ""
|
||||
if res.Truncated {
|
||||
tail = ", но успела посмотреть не всю сеть"
|
||||
}
|
||||
if len(res.Hosts) == 0 {
|
||||
return "в сети никого не нашла" + tail + ".", true
|
||||
}
|
||||
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
if shape := scanShape(res.Hosts); shape != "" {
|
||||
out += ", " + shape
|
||||
}
|
||||
out += tail
|
||||
if w.api != nil {
|
||||
out += ". список записала"
|
||||
}
|
||||
return out + ".", true
|
||||
}
|
||||
|
||||
// scanShape describes the hosts by what they answer on, which is the part of
|
||||
// the answer that carries meaning out loud: "два с вебом" says more about the
|
||||
// flat than four octets do.
|
||||
func scanShape(hosts []netscan.Host) string {
|
||||
var web, ssh, quiet int
|
||||
for _, h := range hosts {
|
||||
hasWeb, hasSSH := false, false
|
||||
for _, p := range h.Ports {
|
||||
switch p {
|
||||
case 80, 443, 8080:
|
||||
hasWeb = true
|
||||
case 22:
|
||||
hasSSH = true
|
||||
}
|
||||
}
|
||||
if hasWeb {
|
||||
web++
|
||||
}
|
||||
if hasSSH {
|
||||
ssh++
|
||||
}
|
||||
// No open port at all: seen only through the ARP cache.
|
||||
if len(h.Ports) == 0 {
|
||||
quiet++
|
||||
}
|
||||
}
|
||||
var parts []string
|
||||
if web > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d с вебом", web))
|
||||
}
|
||||
if ssh > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d с ssh", ssh))
|
||||
}
|
||||
if quiet > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d молча", quiet))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "из них " + strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// writeScanRecord stores the address list as a note. This is both where the
|
||||
// detail becomes readable and the only trace that a scan happened at all: a
|
||||
// scan is a read, but "when did she last put packets on the LAN" deserves an
|
||||
// answer.
|
||||
func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
||||
if w.api == nil {
|
||||
return
|
||||
}
|
||||
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
if res.Truncated {
|
||||
head += " (не вся сеть)"
|
||||
}
|
||||
lines := []string{head, "подсети: " + strings.Join(w.subnets, ", ")}
|
||||
shown := res.Hosts
|
||||
if len(shown) > scanReadOut {
|
||||
shown = shown[:scanReadOut]
|
||||
}
|
||||
for _, h := range shown {
|
||||
s := h.Addr
|
||||
if len(h.Ports) > 0 {
|
||||
ps := make([]string, 0, len(h.Ports))
|
||||
for _, p := range h.Ports {
|
||||
ps = append(ps, fmt.Sprintf("%d", p))
|
||||
}
|
||||
s += " (" + strings.Join(ps, ", ") + ")"
|
||||
}
|
||||
if h.MAC != "" {
|
||||
s += " " + h.MAC
|
||||
}
|
||||
lines = append(lines, s)
|
||||
}
|
||||
if len(res.Hosts) > len(shown) {
|
||||
lines = append(lines, fmt.Sprintf("и ещё %d", len(res.Hosts)-len(shown)))
|
||||
}
|
||||
if _, err := w.api.WriteNote(ctx, w.now(), strings.Join(lines, "\n"), nil, "scan:lan"); err != nil {
|
||||
log.Printf("netscan: write scan note: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
|
||||
// 5+ устройств, and the teens are all the last form.
|
||||
func hostWord(n int) string {
|
||||
if n%100 >= 11 && n%100 <= 14 {
|
||||
return "устройств"
|
||||
}
|
||||
switch n % 10 {
|
||||
case 1:
|
||||
return "устройство"
|
||||
case 2, 3, 4:
|
||||
return "устройства"
|
||||
default:
|
||||
return "устройств"
|
||||
}
|
||||
}
|
||||
|
||||
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
|
||||
// network word AND an ask: "интернет не работает" is a complaint, not a request
|
||||
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
|
||||
// bounds exist to prevent.
|
||||
func isNetworkQuery(u string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(u))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
// Whole tokens for the network nouns: the bare substring "сети" is inside
|
||||
// "посетил", so "сколько машин я посетил?" used to read as a request to
|
||||
// scan the LAN. The prefix forms below are stems that have no such
|
||||
// collisions.
|
||||
network := false
|
||||
for _, w := range []string{"сеть", "сети", "сетке", "сетку"} {
|
||||
if homeWord(s, w) {
|
||||
network = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !network {
|
||||
for _, w := range []string{"локальн", "wifi", "wi-fi", "вайфай"} {
|
||||
if strings.Contains(s, w) {
|
||||
network = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !network {
|
||||
return false
|
||||
}
|
||||
// An explicit ask to scan, or a phrase that can only be about the LAN.
|
||||
// "кто в сети" carries no device noun but means nothing else.
|
||||
for _, w := range []string{"просканируй", "сканируй", "скан", "просканир", "кто в сети", "кто в сетке"} {
|
||||
if strings.Contains(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
ask := strings.Contains(s, "?") || homeWord(s, "какие") || homeWord(s, "кто") ||
|
||||
homeWord(s, "что") || homeWord(s, "сколько") || strings.Contains(s, "покажи")
|
||||
if !ask {
|
||||
return false
|
||||
}
|
||||
for _, w := range []string{"устройств", "хост", "компьютер", "машин", "адрес"} {
|
||||
if strings.Contains(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||
for name, cfg := range map[string]*config.Config{
|
||||
"no block": {},
|
||||
"written but dark": {NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"192.168.1.0/24"},
|
||||
}},
|
||||
"enabled but nothing to scan": {NetScan: &config.NetScanConfig{Enabled: true}},
|
||||
"enabled but public": {NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"8.8.8.0/24"}, Enabled: true,
|
||||
}},
|
||||
"enabled but far too wide": {NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"10.0.0.0/8"}, Enabled: true,
|
||||
}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if w := wireNetScan(cfg, nil); w != nil {
|
||||
t.Fatal("the scanner must not wire for this config")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var w *netWiring
|
||||
if _, ok := w.scanSummary(context.Background()); ok {
|
||||
t.Fatal("a nil wiring must not claim a query")
|
||||
}
|
||||
|
||||
ok := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"192.168.1.0/24"}, Enabled: true,
|
||||
}}, nil)
|
||||
if ok == nil {
|
||||
t.Fatal("a valid enabled block should wire")
|
||||
}
|
||||
}
|
||||
|
||||
// A loopback /32 with nothing listening on the scanned port: the summary must
|
||||
// come back honest rather than inventing a host. This also exercises the real
|
||||
// dialer end to end without touching anything outside this box.
|
||||
func TestScanSummaryOnAnEmptyRange(t *testing.T) {
|
||||
w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||
// Port 1 on loopback: nothing listens and the connection is refused
|
||||
// immediately, so the scan is fast and touches only this machine.
|
||||
Subnets: []string{"127.0.0.1/32"}, Ports: []int{1}, Rate: 1000, Enabled: true,
|
||||
}}, nil)
|
||||
if w == nil {
|
||||
t.Fatal("wireNetScan returned nil")
|
||||
}
|
||||
out, claimed := w.scanSummary(context.Background())
|
||||
if !claimed {
|
||||
t.Fatal("the summary did not claim the turn")
|
||||
}
|
||||
if out == "" {
|
||||
t.Fatal("empty summary")
|
||||
}
|
||||
// Persona: feminine self-reference, informal address, no pet names.
|
||||
low := strings.ToLower(out)
|
||||
for _, bad := range []string{"нашёл", "не смог ", "вы ", "ваш", "милый", "дорогой"} {
|
||||
if strings.Contains(low, bad) {
|
||||
t.Errorf("persona violation %q in %q", bad, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostWordAgreesWithTheCount(t *testing.T) {
|
||||
for n, want := range map[int]string{
|
||||
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
|
||||
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
|
||||
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
|
||||
} {
|
||||
if got := hostWord(n); got != want {
|
||||
t.Errorf("hostWord(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNetworkQuery(t *testing.T) {
|
||||
yes := []string{
|
||||
"какие устройства в сети?",
|
||||
"кто в сети?",
|
||||
"просканируй сеть",
|
||||
"покажи устройства в локальной сети",
|
||||
"сколько машин в сети",
|
||||
}
|
||||
no := []string{
|
||||
"",
|
||||
"интернет не работает",
|
||||
"сеть какая-то медленная",
|
||||
"я в сети инстаграма",
|
||||
"что включено дома?",
|
||||
"напомни оплатить интернет",
|
||||
}
|
||||
for _, u := range yes {
|
||||
if !isNetworkQuery(u) {
|
||||
t.Errorf("isNetworkQuery(%q) = false, want true", u)
|
||||
}
|
||||
}
|
||||
for _, u := range no {
|
||||
if isNetworkQuery(u) {
|
||||
t.Errorf("isNetworkQuery(%q) = true, want false", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notingAPI counts the notes a scan writes, and remembers the last one.
|
||||
type notingAPI struct {
|
||||
ipc.CoreAPI
|
||||
n int
|
||||
last string
|
||||
}
|
||||
|
||||
func (a *notingAPI) WriteNote(_ context.Context, _ time.Time, text string, _ []float32, _ string) (int64, error) {
|
||||
a.n++
|
||||
a.last = text
|
||||
return int64(a.n), nil
|
||||
}
|
||||
|
||||
// The spoken answer must not be a list of IP addresses. It goes to piper as
|
||||
// well as to /chat, and six dotted quads read out as a digit stream is not an
|
||||
// answer anybody can use. The addresses belong in the written record.
|
||||
func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
api := ¬ingAPI{}
|
||||
w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"127.0.0.1/32"}, Ports: []int{port}, Rate: 1000, Enabled: true,
|
||||
}}, api)
|
||||
if w == nil {
|
||||
t.Fatal("wireNetScan returned nil")
|
||||
}
|
||||
out, claimed := w.scanSummary(context.Background())
|
||||
if !claimed {
|
||||
t.Fatal("the summary did not claim the turn")
|
||||
}
|
||||
if strings.Contains(out, "127.0.0.1") || strings.Contains(out, portStr) {
|
||||
t.Errorf("the spoken reply reads addresses out loud: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "нашла 1 устройство") {
|
||||
t.Errorf("reply = %q, want a count", out)
|
||||
}
|
||||
if api.n != 1 {
|
||||
t.Fatalf("wrote %d notes, want 1", api.n)
|
||||
}
|
||||
if !strings.Contains(api.last, "127.0.0.1") {
|
||||
t.Errorf("the written record has no addresses: %q", api.last)
|
||||
}
|
||||
|
||||
// A follow-up question inside the TTL reuses the answer: two questions in
|
||||
// a row must not be two sweeps of the LAN.
|
||||
if _, _ = w.scanSummary(context.Background()); api.n != 1 {
|
||||
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,14 @@ import (
|
||||
// both the voice path and the text path (mavweb /api/chat, telegram) reach it,
|
||||
// so a false positive here is a network-reachable way to flip a daemon-wide
|
||||
// setting. See classifyQuietToggle for the matching rule.
|
||||
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (string, bool) {
|
||||
//
|
||||
// src is the channel the utterance arrived on, and it is written straight into
|
||||
// the fact. Every toggle used to be stored as "tap:voice", including the ones
|
||||
// typed into the web UI, which left the facts table claiming a microphone flipped
|
||||
// a setting nobody spoke to. This is the one function where that matters most:
|
||||
// when he goes looking at why quiet mode is on, provenance is the first column
|
||||
// he reads.
|
||||
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, src turnSource) (string, bool) {
|
||||
on, off := classifyQuietToggle(text)
|
||||
if !on && !off {
|
||||
return "", false
|
||||
@@ -35,7 +42,7 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (
|
||||
Kind: "config",
|
||||
Key: "quiet_hours",
|
||||
Value: val,
|
||||
Source: "tap:voice",
|
||||
Source: string(src),
|
||||
Confidence: 1.0,
|
||||
}); err != nil {
|
||||
log.Printf("voice: write quiet_hours: %v", err)
|
||||
@@ -110,11 +117,18 @@ func quietPhrase(tokens, pattern []string) bool {
|
||||
}
|
||||
|
||||
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
|
||||
//
|
||||
// Note what is NOT here any more: the OFF list used to carry {"не", "тих"} and
|
||||
// the ON list {"не", "шум"} / {"не", "беспоко"}. Both were adjacency patterns,
|
||||
// and negation is not an adjacency phenomenon. "не надо тихий режим" put two
|
||||
// tokens between "не" and "тих", so the OFF pattern missed, the ON pattern
|
||||
// {"тих","режим"} matched, and asking for quiet mode to stop turned it on.
|
||||
// Negation is handled by quietNegators below, over the whole utterance.
|
||||
var (
|
||||
quietOffPhrases = [][]string{
|
||||
{"quiet", "off"}, {"quiet", "end"},
|
||||
{"громк", "режим"}, {"шумн", "режим"},
|
||||
{"отмен", "тих"}, {"выключ", "тих"}, {"не", "тих"},
|
||||
{"отмен", "тих"}, {"выключ", "тих"},
|
||||
}
|
||||
quietOnPhrases = [][]string{
|
||||
{"quiet", "on"}, {"quiet", "mode"},
|
||||
@@ -123,11 +137,44 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// classifyQuietToggle reads an utterance as a quiet-mode command. OFF is
|
||||
// resolved before ON for the same reason classifyConfirm checks negatives
|
||||
// first: the OFF phrases are built out of the ON words ("выключи тихий"
|
||||
// contains "тихий"), so scanning ON first would shadow them and "выключи
|
||||
// тихий режим" would turn quiet mode on. Negation wins.
|
||||
// quietNegatorWords — negators that are whole words with no useful stem.
|
||||
var quietNegatorWords = map[string]bool{
|
||||
"не": true, "нет": true, "хватит": true, "no": true, "not": true, "off": true,
|
||||
}
|
||||
|
||||
// quietNegatorStems — negators that inflect. Matched through quietStem, the
|
||||
// same one-ending rule the toggle vocabulary uses, so "выключи", "выключить"
|
||||
// and "выключай" all count and "выключатель" does not.
|
||||
var quietNegatorStems = []string{"выключ", "отмен", "прекрат", "убер", "stop", "cancel", "disable"}
|
||||
|
||||
// quietNegated reports whether the utterance carries a negator. Two ON phrases
|
||||
// are themselves built on "не" — "не шуми", "не беспокой" — and those are
|
||||
// requests FOR quiet, so they are excluded before the scan: a negator only
|
||||
// counts when it is not part of the phrase that matched.
|
||||
func quietNegated(tokens []string, matched []string) bool {
|
||||
if len(matched) > 0 && matched[0] == "не" {
|
||||
return false
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if quietNegatorWords[t] {
|
||||
return true
|
||||
}
|
||||
for _, stem := range quietNegatorStems {
|
||||
if quietStem(t, stem) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// classifyQuietToggle reads an utterance as a quiet-mode command.
|
||||
//
|
||||
// Explicit OFF phrases resolve first, for the same reason classifyConfirm
|
||||
// checks negatives first: they are built out of the ON words ("выключи тихий"
|
||||
// contains "тихий"), so scanning ON first would shadow them. An ON phrase that
|
||||
// matches is then checked for negation across the whole utterance, so any way
|
||||
// of saying "not quiet mode" turns it off rather than on.
|
||||
func classifyQuietToggle(text string) (on, off bool) {
|
||||
tokens := quietTokens(text)
|
||||
for _, p := range quietOffPhrases {
|
||||
@@ -137,8 +184,23 @@ func classifyQuietToggle(text string) (on, off bool) {
|
||||
}
|
||||
for _, p := range quietOnPhrases {
|
||||
if quietPhrase(tokens, p) {
|
||||
if quietNegated(tokens, p) {
|
||||
return false, true
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
}
|
||||
// No ON phrase matched, but he negated a quiet word: "не тихо", "хватит
|
||||
// тихого режима". The ON vocabulary cannot see these — bare "тих" only
|
||||
// matches a one-token utterance, by design, so the negator pushes the token
|
||||
// count past it — and reading them as "no command" would leave quiet mode
|
||||
// on after he asked for it to stop.
|
||||
if quietNegated(tokens, nil) {
|
||||
for _, t := range tokens {
|
||||
if quietStem(t, "тих") {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestResolveQuietToggle(t *testing.T) {
|
||||
t.Run(tc.text, func(t *testing.T) {
|
||||
api := &quietFakeAPI{}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return time.Unix(0, 0).UTC() }}
|
||||
reply, handled := h.resolveQuietToggle(context.Background(), tc.text)
|
||||
reply, handled := h.resolveQuietToggle(context.Background(), tc.text, sourceVoice)
|
||||
|
||||
if tc.want == quietNone {
|
||||
if handled || reply != "" {
|
||||
@@ -112,3 +112,57 @@ func TestResolveQuietToggle(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuietToggleNegationIsNotAdjacency — negation used to be an adjacency
|
||||
// pattern ({"не","тих"} in the OFF list), so any word between the negator and
|
||||
// the quiet word made the ON pattern win and asking for quiet mode to STOP
|
||||
// turned it on. Negation is scanned over the whole utterance now.
|
||||
func TestQuietToggleNegationIsNotAdjacency(t *testing.T) {
|
||||
off := []string{
|
||||
"не надо тихий режим",
|
||||
"не хочу тихий режим",
|
||||
"тихий режим выключи",
|
||||
"убери тихий режим",
|
||||
"хватит тихого режима",
|
||||
"прекрати тихий режим",
|
||||
"тихий режим отмени пожалуйста",
|
||||
}
|
||||
for _, text := range off {
|
||||
t.Run(text, func(t *testing.T) {
|
||||
on, isOff := classifyQuietToggle(text)
|
||||
if on || !isOff {
|
||||
t.Fatalf("%q: want OFF, got on=%v off=%v", text, on, isOff)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The two ON phrases that are themselves built on "не" must stay ON: they
|
||||
// are requests FOR quiet, not negations of one.
|
||||
for _, text := range []string{"не шуми", "не беспокоить"} {
|
||||
t.Run(text, func(t *testing.T) {
|
||||
on, isOff := classifyQuietToggle(text)
|
||||
if !on || isOff {
|
||||
t.Fatalf("%q: want ON, got on=%v off=%v", text, on, isOff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuietToggleRecordsTheChannelItArrivedOn — the toggle is reachable from
|
||||
// mavweb /api/chat and telegram, not only the microphone. Every write used to
|
||||
// be stamped "tap:voice", so a toggle typed into the web UI claimed a mic wrote
|
||||
// it and the provenance column lied about a daemon-wide setting.
|
||||
func TestQuietToggleRecordsTheChannelItArrivedOn(t *testing.T) {
|
||||
for _, src := range []turnSource{sourceVoice, sourceText} {
|
||||
t.Run(string(src), func(t *testing.T) {
|
||||
api := &quietFakeAPI{}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return time.Unix(0, 0).UTC() }}
|
||||
if _, handled := h.resolveQuietToggle(context.Background(), "тихий режим", src); !handled {
|
||||
t.Fatal("expected the toggle to match")
|
||||
}
|
||||
if api.got.Source != string(src) {
|
||||
t.Errorf("source = %q, want %q", api.got.Source, src)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/smarthome"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// homeWiring — the Home Assistant client, when the `smarthome` block is present
|
||||
// AND enabled. nil ⇒ the house is not wired, nothing was proposed, and an
|
||||
// allowlist row that happens to look like a house row refuses to run.
|
||||
//
|
||||
// It lives on the voice wiring for the same reason MCP does: a house control IS
|
||||
// an act. It goes through tool.Executor, the enabled allowlist and the confirm
|
||||
// turn, all of which only exist on the voice/chat path.
|
||||
type homeWiring struct {
|
||||
client *smarthome.Client
|
||||
st *store.Store
|
||||
refresh time.Duration
|
||||
}
|
||||
|
||||
// wireSmartHome builds the client and proposes what it found. It never fails
|
||||
// the daemon: an instance that is down at boot is logged and retried, because
|
||||
// Maven starting is not contingent on someone else's process.
|
||||
func wireSmartHome(cfg *config.Config, st *store.Store) *homeWiring {
|
||||
hc, ok := cfg.SmartHomeClient()
|
||||
if !ok || st == nil {
|
||||
return nil
|
||||
}
|
||||
if err := smarthome.Validate(hc); err != nil {
|
||||
// config.validate already ran this, so reaching here is a programming
|
||||
// error rather than a config one. Still not fatal: the house off is a
|
||||
// working Maven.
|
||||
log.Printf("smarthome: not wired: %v", err)
|
||||
return nil
|
||||
}
|
||||
w := &homeWiring{
|
||||
client: smarthome.NewClient(hc),
|
||||
st: st,
|
||||
refresh: time.Duration(cfg.SmartHome.Refresh),
|
||||
}
|
||||
// No first propose here. This runs inside wireVoice, inside run, before the
|
||||
// IPC socket is serving, and on the locked path inside the passkey unlock
|
||||
// handler. A Home Assistant box that is powered off but still on a routed
|
||||
// subnet black-holes the connection rather than refusing it, so a
|
||||
// synchronous enumeration held the daemon's start for the per-call timeout.
|
||||
// run does the first propose off the ticker instead.
|
||||
return w
|
||||
}
|
||||
|
||||
// caller is the tool.HomeCaller seam.
|
||||
func (w *homeWiring) caller() *smarthome.Client {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.client
|
||||
}
|
||||
|
||||
// propose writes a 'proposed' allowlist row for every controllable device. It
|
||||
// does NOT enable anything: a reachable house is a place Maven may look, not a
|
||||
// set of switches she may flip. Kami enables what he wants on /tools, behind
|
||||
// step-up, which is the same gate a shell tool goes through.
|
||||
//
|
||||
// Sensors are read but never proposed — there is nothing to call on them.
|
||||
func (w *homeWiring) propose(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
ents, err := w.client.States(ctx)
|
||||
if err != nil {
|
||||
log.Printf("smarthome: read states: %v", err)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
fresh, devices := 0, 0
|
||||
for _, e := range ents {
|
||||
svcs := smarthome.Services(e.Domain)
|
||||
if len(svcs) == 0 {
|
||||
continue
|
||||
}
|
||||
devices++
|
||||
for _, s := range svcs {
|
||||
name := smarthome.LocalName(e.ID, s.Verb)
|
||||
provenance := "дом: " + s.Name + " → " + e.Name + " (" + e.ID + ")"
|
||||
ok, err := w.st.ProposeSmartHomeTool(ctx, name, smarthome.Scope(e.Domain),
|
||||
smarthome.Cmd(e.ID, s.Name), provenance, now)
|
||||
if err != nil {
|
||||
log.Printf("smarthome: propose %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
fresh++
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("smarthome: %d entities, %d controllable", len(ents), devices)
|
||||
if fresh > 0 {
|
||||
log.Printf("smarthome: %d new device proposal(s) waiting on /tools", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
// run re-enumerates the house and picks up devices that appeared, until ctx is
|
||||
// canceled.
|
||||
func (w *homeWiring) run(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
iv := w.refresh
|
||||
if iv <= 0 {
|
||||
iv = config.DefaultSmartHomeRefresh
|
||||
}
|
||||
t := time.NewTicker(iv)
|
||||
defer t.Stop()
|
||||
// The first enumeration, off the daemon's start path. wireSmartHome used to
|
||||
// do it synchronously and a dead house delayed the socket coming up.
|
||||
w.propose(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
w.propose(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// homeSummary answers "что дома?" — a read of the current entity states, one
|
||||
// short line. Read-only: it can never call a service, so it needs no confirm
|
||||
// and no allowlist row.
|
||||
func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
||||
if w == nil {
|
||||
return "", false
|
||||
}
|
||||
ents, err := w.client.States(ctx)
|
||||
if err != nil {
|
||||
log.Printf("smarthome: summary: %v", err)
|
||||
return "не смогла достучаться до дома.", true
|
||||
}
|
||||
if len(ents) == 0 {
|
||||
return "дом ничего не отдаёт.", true
|
||||
}
|
||||
var on []string
|
||||
var sensors []string
|
||||
dark := 0
|
||||
for _, e := range ents {
|
||||
switch {
|
||||
case e.Domain == "sensor" || e.Domain == "binary_sensor":
|
||||
if e.State == "" || e.State == "unavailable" {
|
||||
dark++
|
||||
continue
|
||||
}
|
||||
if len(sensors) < 3 {
|
||||
sensors = append(sensors, e.Name+" "+e.State+e.Unit)
|
||||
}
|
||||
case e.State == "unavailable" || e.State == "unknown" || e.State == "":
|
||||
// A lamp that is not reachable is not a lamp that is off. Counting
|
||||
// it as neither used to make "всё выключено" and "one device is
|
||||
// unreachable" read identically.
|
||||
dark++
|
||||
case e.State == "on" || e.State == "open" || e.State == "unlocked":
|
||||
on = append(on, e.Name)
|
||||
}
|
||||
}
|
||||
var parts []string
|
||||
switch {
|
||||
case len(on) > 0:
|
||||
shown, rest := on, 0
|
||||
if len(shown) > 5 {
|
||||
rest = len(shown) - 5
|
||||
shown = shown[:5]
|
||||
}
|
||||
// Silent truncation on a status read is the same failure as the cap
|
||||
// one layer up: she has to say the list is not the whole list.
|
||||
line := "включено: " + strings.Join(shown, ", ")
|
||||
if rest > 0 {
|
||||
line += fmt.Sprintf(" и ещё %d", rest)
|
||||
}
|
||||
parts = append(parts, line)
|
||||
case dark > 0 && len(sensors) == 0:
|
||||
// Nothing is on and everything she can see is unreachable. "всё
|
||||
// выключено" would be a claim about the house she cannot make.
|
||||
return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, hostWord(dark)), true
|
||||
default:
|
||||
parts = append(parts, "всё выключено")
|
||||
}
|
||||
if len(sensors) > 0 {
|
||||
parts = append(parts, strings.Join(sensors, ", "))
|
||||
}
|
||||
if dark > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, hostWord(dark)))
|
||||
}
|
||||
return strings.Join(parts, "; ") + ".", true
|
||||
}
|
||||
|
||||
// isHomeQuery recognises a question about the house, narrowly. "дома" on its
|
||||
// own is not enough — "я дома" is a fact, not a question — so it takes a house
|
||||
// marker AND an ask AND either a device word or the word "включ…". Weather
|
||||
// wording bails out first: "какая температура на улице?" belongs to the weather
|
||||
// source, and both questions contain "температура".
|
||||
func isHomeQuery(u string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(u))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, w := range []string{"погод", "на улице", "прогноз"} {
|
||||
if strings.Contains(s, w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, phrase := range []string{"что включено", "что выключено", "умный дом", "что в доме включено"} {
|
||||
if strings.Contains(s, phrase) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
house := homeWord(s, "дома") || strings.Contains(s, "в доме") || strings.Contains(s, "в квартире")
|
||||
if !house {
|
||||
return false
|
||||
}
|
||||
ask := strings.Contains(s, "?") || homeWord(s, "что") || homeWord(s, "какая") ||
|
||||
homeWord(s, "какой") || homeWord(s, "сколько")
|
||||
if !ask {
|
||||
return false
|
||||
}
|
||||
for _, w := range []string{"свет", "лампа", "лампы", "розетк", "датчик", "температур", "включ", "выключ"} {
|
||||
if strings.Contains(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// homeWord — whole-token membership, so "дома" does not fire on "домашний".
|
||||
// Punctuation is trimmed off each token because a spoken question arrives with
|
||||
// a question mark glued to the last word.
|
||||
func homeWord(s, w string) bool {
|
||||
for _, tok := range strings.Fields(s) {
|
||||
if strings.Trim(tok, ".,!?;:") == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
)
|
||||
|
||||
const haStatesFixture = `[
|
||||
{"entity_id":"light.living_room","state":"on","attributes":{"friendly_name":"Гостиная"}},
|
||||
{"entity_id":"switch.kettle","state":"off","attributes":{"friendly_name":"Чайник"}},
|
||||
{"entity_id":"sensor.bedroom_temp","state":"22.5","attributes":{"friendly_name":"Спальня","unit_of_measurement":"°C"}}
|
||||
]`
|
||||
|
||||
func TestWireSmartHomeOffUnlessEnabled(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
for name, cfg := range map[string]*config.Config{
|
||||
"no block": {},
|
||||
"written but dark": {SmartHome: &config.SmartHomeConfig{
|
||||
URL: "http://ha.lan:8123", Token: "t",
|
||||
}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if w := wireSmartHome(cfg, st); w != nil {
|
||||
t.Fatal("the house must be off unless the block is enabled")
|
||||
}
|
||||
})
|
||||
}
|
||||
// nil wiring must be safe everywhere it is reachable.
|
||||
var w *homeWiring
|
||||
w.propose(context.Background())
|
||||
w.run(context.Background())
|
||||
if w.caller() != nil {
|
||||
t.Fatal("a nil wiring must have no caller")
|
||||
}
|
||||
if _, ok := w.homeSummary(context.Background()); ok {
|
||||
t.Fatal("a nil wiring must not claim a query")
|
||||
}
|
||||
}
|
||||
|
||||
// An unreachable instance must not stop the daemon and must propose nothing.
|
||||
func TestWireSmartHomeUnreachableIsNotFatal(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
// Port 1 on loopback: nothing listens, and it fails fast.
|
||||
URL: "http://127.0.0.1:1", Token: "t", Enabled: true,
|
||||
}}, st)
|
||||
if w == nil {
|
||||
t.Fatal("a configured house should still wire")
|
||||
}
|
||||
tools, err := st.ListTools(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tools) != 0 {
|
||||
t.Fatalf("an instance that never answered must propose nothing, got %+v", tools)
|
||||
}
|
||||
}
|
||||
|
||||
// Discovery proposes one row per controllable service, always destructive,
|
||||
// always 'proposed'. A sensor gets no row: there is nothing to call on it.
|
||||
func TestProposeOnlyProposesControllableDevices(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(haStatesFixture))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
st := newTestStore(t)
|
||||
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, st)
|
||||
if w == nil {
|
||||
t.Fatal("wireSmartHome returned nil for an enabled, reachable house")
|
||||
}
|
||||
// Wiring alone must not have touched the house: enumeration happens off
|
||||
// the ticker, not on the daemon's start path.
|
||||
if pre, err := st.ListTools(context.Background(), ""); err != nil || len(pre) != 0 {
|
||||
t.Fatalf("wireSmartHome enumerated the house synchronously: %+v (%v)", pre, err)
|
||||
}
|
||||
w.propose(context.Background())
|
||||
|
||||
tools, err := st.ListTools(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, tl := range tools {
|
||||
got[tl.Name] = true
|
||||
if tl.Status != "proposed" {
|
||||
t.Errorf("%s status = %q: discovery must never enable", tl.Name, tl.Status)
|
||||
}
|
||||
if !tl.Destructive {
|
||||
t.Errorf("%s is not destructive: every house control needs the confirm turn", tl.Name)
|
||||
}
|
||||
if len(tl.Cmd) == 0 || tl.Cmd[0] != "smarthome" {
|
||||
t.Errorf("%s cmd = %v", tl.Name, tl.Cmd)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"home_light_living_room_on", "home_light_living_room_off",
|
||||
"home_switch_kettle_on", "home_switch_kettle_off",
|
||||
} {
|
||||
if !got[want] {
|
||||
t.Errorf("missing proposal %q (have %v)", want, got)
|
||||
}
|
||||
}
|
||||
if len(tools) != 4 {
|
||||
t.Fatalf("got %d rows, want 4 — the sensor must not be proposed: %+v", len(tools), tools)
|
||||
}
|
||||
|
||||
// A second pass must be idempotent: re-discovery duplicates nothing and
|
||||
// never rewrites a row Kami already enabled.
|
||||
if err := st.EnableTool(context.Background(), "home_switch_kettle_on",
|
||||
[]string{"smarthome", "switch.kettle", "turn_on"}, true, "smarthome:switch", time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.propose(context.Background())
|
||||
again, err := st.ListTools(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(again) != 4 {
|
||||
t.Fatalf("re-discovery duplicated rows: %d", len(again))
|
||||
}
|
||||
for _, tl := range again {
|
||||
if tl.Name == "home_switch_kettle_on" && tl.Status != "enabled" {
|
||||
t.Errorf("re-discovery un-enabled a device he had enabled: %q", tl.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeSummaryReadsState(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(haStatesFixture))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, newTestStore(t))
|
||||
out, ok := w.homeSummary(context.Background())
|
||||
if !ok {
|
||||
t.Fatal("summary did not claim the turn")
|
||||
}
|
||||
if !strings.Contains(out, "Гостиная") {
|
||||
t.Errorf("the lamp that is on should be named: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "Чайник") {
|
||||
t.Errorf("a device that is off should not be listed as on: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "22.5") {
|
||||
t.Errorf("the sensor reading should be there: %q", out)
|
||||
}
|
||||
// Persona: no masculine self-reference, no "вы", no pet names.
|
||||
for _, bad := range []string{"рад ", "готов ", "вы ", "ваш", "милый", "дорогой"} {
|
||||
if strings.Contains(strings.ToLower(out), bad) {
|
||||
t.Errorf("persona violation %q in %q", bad, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHomeQuery(t *testing.T) {
|
||||
yes := []string{
|
||||
"что включено дома?",
|
||||
"что выключено",
|
||||
"какой свет горит дома",
|
||||
"свет в доме включен?",
|
||||
"какая температура в квартире?",
|
||||
"покажи умный дом",
|
||||
}
|
||||
no := []string{
|
||||
"",
|
||||
"я дома",
|
||||
"буду дома в семь",
|
||||
"какая погода дома", // weather wording wins
|
||||
"какая температура на улице?",
|
||||
"домашние дела", // "дома" must not fire on "домашние"
|
||||
"что мне нужно сделать?",
|
||||
"напомни выключить чайник в семь", // a reminder, not a house read
|
||||
}
|
||||
for _, u := range yes {
|
||||
if !isHomeQuery(u) {
|
||||
t.Errorf("isHomeQuery(%q) = false, want true", u)
|
||||
}
|
||||
}
|
||||
for _, u := range no {
|
||||
if isHomeQuery(u) {
|
||||
t.Errorf("isHomeQuery(%q) = true, want false", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A house that black-holes the connection must not hold the daemon's start.
|
||||
// wireSmartHome used to enumerate synchronously with a 30s context, inside
|
||||
// wireVoice, inside run, before the IPC socket was serving — and on the locked
|
||||
// path, inside the passkey unlock handler.
|
||||
func TestWireSmartHomeDoesNotBlockOnTheHouse(t *testing.T) {
|
||||
// A handler that never answers: the client's own timeout is the only way
|
||||
// out, and it is ten seconds.
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-block
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
done := make(chan *homeWiring, 1)
|
||||
go func() {
|
||||
done <- wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, newTestStore(t))
|
||||
}()
|
||||
select {
|
||||
case w := <-done:
|
||||
if w == nil {
|
||||
t.Fatal("a configured house should still wire")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("wireSmartHome waited on the house")
|
||||
}
|
||||
}
|
||||
|
||||
// A lamp that is unreachable is not a lamp that is off, and a list she cut
|
||||
// short has to say so. Both used to read as plain statements about the house.
|
||||
func TestHomeSummaryDoesNotCallUnreachableDevicesOff(t *testing.T) {
|
||||
const fixture = `[
|
||||
{"entity_id":"light.a","state":"unavailable","attributes":{"friendly_name":"Прихожая"}},
|
||||
{"entity_id":"light.b","state":"unavailable","attributes":{"friendly_name":"Кухня"}}
|
||||
]`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(fixture))
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, newTestStore(t))
|
||||
out, ok := w.homeSummary(context.Background())
|
||||
if !ok {
|
||||
t.Fatal("summary did not claim the turn")
|
||||
}
|
||||
if strings.Contains(out, "всё выключено") {
|
||||
t.Errorf("two unreachable lamps were reported as off: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "не отвечают") {
|
||||
t.Errorf("the unreachable devices are not mentioned: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeSummarySaysWhenTheListIsCutShort(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i := 0; i < 8; i++ {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"entity_id":"light.l%d","state":"on","attributes":{"friendly_name":"лампа%d"}}`, i, i)
|
||||
}
|
||||
b.WriteString("]")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(b.String()))
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, newTestStore(t))
|
||||
out, _ := w.homeSummary(context.Background())
|
||||
if !strings.Contains(out, "и ещё 3") {
|
||||
t.Errorf("eight lamps on, five named, and nothing said about the rest: %q", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// mavend/speaker.go — core's half of voice identification (Vikunja #255,
|
||||
// docs/plans/10-speaker-recognition.md).
|
||||
//
|
||||
// # What is actually wired here, and what is not
|
||||
//
|
||||
// Nothing is, on this box. There is no speaker-embedding model on disk — no
|
||||
// ECAPA, no x-vector, no titanet, no wespeaker, nothing in /mnt/hdd1/llms but
|
||||
// text ggufs. Until one is downloaded, newSpeakerEmbedder returns nil.
|
||||
//
|
||||
// Without an embedder the capability has no runnable half. This comment used to
|
||||
// say enrolment was real and only recognition was blocked, and the startup log
|
||||
// said the same. Both were wrong: Recognizer.Enroll embeds every sample before
|
||||
// it stores anything, so with no model it fails on the first sample and nothing
|
||||
// is ever stored, which leaves List empty forever and Forget with nothing to
|
||||
// delete. So the gate is cfg.Speaker.Recognizes() — enabled AND a model path —
|
||||
// and a box without one gets no speaker methods, not three no-ops.
|
||||
//
|
||||
// This is deliberately not papered over with a hand-rolled MFCC floor. A
|
||||
// biometric that is confidently wrong writes false claims about named people
|
||||
// into his memory, and that is worse than a capability that is honestly absent.
|
||||
//
|
||||
// # Off unless configured
|
||||
//
|
||||
// No speaker block, or one without enabled, ⇒ the three methods do not exist and
|
||||
// answer ErrUnknownMethod. On an unconfigured box there is no wire path that
|
||||
// takes a voiceprint at all.
|
||||
//
|
||||
// # The refused design step
|
||||
//
|
||||
// The plan asks for unknown speakers to be enrolled on first interaction. That
|
||||
// is refused in internal/speaker/enroll.go and there is no handler for it here:
|
||||
// no request shape in the protocol enrols whoever just spoke. Taking a biometric
|
||||
// of a guest who walked past the microphone is not something this daemon does.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/speaker"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// speakerWiring holds the recognizer behind the three IPC handlers.
|
||||
type speakerWiring struct {
|
||||
rec *speaker.Recognizer
|
||||
}
|
||||
|
||||
// newSpeakerEmbedder loads the speaker-embedding model named by the config.
|
||||
//
|
||||
// It always returns nil today. The seam exists so that wiring a real model is a
|
||||
// change to this one function and nothing else: give it a loader, and Identify
|
||||
// starts working with no change to the store, the protocol, the auth table or
|
||||
// the handlers. See the plan document for what to download.
|
||||
func newSpeakerEmbedder(cfg *config.SpeakerConfig) speaker.Embedder {
|
||||
_ = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// newSpeakerWiring builds the recognizer, or nil when the capability is off.
|
||||
func newSpeakerWiring(st *store.Store, cfg *config.Config) *speakerWiring {
|
||||
if cfg == nil || cfg.Speaker == nil {
|
||||
return nil
|
||||
}
|
||||
if !cfg.Speaker.Recognizes() {
|
||||
// Recognizes() was written as the gate and documented as one, and then
|
||||
// never called. "enabled": true with no model_path used to wire all
|
||||
// three methods and log "enrolment on", which is the one config shape
|
||||
// where the operator most needs to be told otherwise.
|
||||
if cfg.Speaker.Enabled {
|
||||
log.Print("speaker: enabled but no model_path, so there is nothing to embed with; " +
|
||||
"enrol, list and forget would all be no-ops, staying off " +
|
||||
"(see docs/plans/10-speaker-recognition.md)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if st == nil {
|
||||
log.Print("speaker: enabled but there is no store to keep profiles in; staying off")
|
||||
return nil
|
||||
}
|
||||
rec, err := speaker.New(newSpeakerEmbedder(cfg.Speaker), st.VectorMemory(), speaker.Config{
|
||||
Threshold: cfg.Speaker.Threshold,
|
||||
MinSeconds: cfg.Speaker.MinSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("speaker: %v; staying off", err)
|
||||
return nil
|
||||
}
|
||||
if rec.Enabled() {
|
||||
log.Printf("speaker: recognition on, threshold %.2f", rec.Threshold())
|
||||
} else {
|
||||
log.Printf("speaker: model_path %q is configured but no embedding backend is built yet, "+
|
||||
"so enrol, list and forget are all no-ops (Vikunja #255)", cfg.Speaker.ModelPath)
|
||||
}
|
||||
return &speakerWiring{rec: rec}
|
||||
}
|
||||
|
||||
func (w *speakerWiring) enroll(ctx context.Context, req ipc.EnrollSpeakerReq) (ipc.EnrollSpeakerResp, error) {
|
||||
p, err := w.rec.Enroll(ctx, req.ID, req.Name, req.Samples)
|
||||
if err != nil {
|
||||
return ipc.EnrollSpeakerResp{}, speakerErr(err)
|
||||
}
|
||||
return ipc.EnrollSpeakerResp{Speaker: toWireSpeaker(p)}, nil
|
||||
}
|
||||
|
||||
func (w *speakerWiring) list(ctx context.Context) (ipc.ListSpeakersResp, error) {
|
||||
ps, err := w.rec.List(ctx)
|
||||
if err != nil {
|
||||
return ipc.ListSpeakersResp{}, speakerErr(err)
|
||||
}
|
||||
out := make([]ipc.Speaker, 0, len(ps))
|
||||
for _, p := range ps {
|
||||
out = append(out, toWireSpeaker(p))
|
||||
}
|
||||
return ipc.ListSpeakersResp{Speakers: out, Enabled: w.rec.Enabled()}, nil
|
||||
}
|
||||
|
||||
func (w *speakerWiring) forget(ctx context.Context, req ipc.ForgetSpeakerReq) error {
|
||||
return speakerErr(w.rec.Forget(ctx, req.ID))
|
||||
}
|
||||
|
||||
// toWireSpeaker drops the voiceprint. A listing says who is enrolled; it does
|
||||
// not hand the biometric back out over the socket.
|
||||
func toWireSpeaker(p speaker.Profile) ipc.Speaker {
|
||||
return ipc.Speaker{ID: p.ID, Name: p.Name, Enrolled: p.Enrolled, Samples: p.Samples, Damaged: p.Damaged}
|
||||
}
|
||||
|
||||
// speakerErr maps the package sentinels onto the wire vocabulary so a surface
|
||||
// can tell "you asked wrong" from "core broke".
|
||||
func speakerErr(err error) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, speaker.ErrDisabled):
|
||||
// Not a core failure. The capability is present on the wire but has no
|
||||
// embedding model behind it, which is the same thing an unconfigured
|
||||
// method says, so say it the same way.
|
||||
return ipc.ErrUnknownMethod
|
||||
case errors.Is(err, speaker.ErrNotFound):
|
||||
return ipc.ErrNoFact
|
||||
case errors.Is(err, speaker.ErrBadID),
|
||||
errors.Is(err, speaker.ErrBadFormat),
|
||||
errors.Is(err, speaker.ErrTooShort):
|
||||
return errors.Join(ipc.ErrBadParams, err)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// wireSpeaker attaches the three handlers when the capability is configured.
|
||||
func wireSpeaker(srv *ipc.Server, st *store.Store, cfg *config.Config) {
|
||||
w := newSpeakerWiring(st, cfg)
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
srv.EnrollSpeakerFn = w.enroll
|
||||
srv.ListSpeakersFn = w.list
|
||||
srv.ForgetSpeakerFn = w.forget
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/speaker"
|
||||
)
|
||||
|
||||
// "enabled": true with no model_path used to wire all three methods and log
|
||||
// "enrolment on". Nothing behind them works without an embedder, so the
|
||||
// capability stays off and the socket answers "no such method".
|
||||
func TestSpeakerStaysOffWithoutAModelPath(t *testing.T) {
|
||||
srv := &ipc.Server{}
|
||||
cfg := &config.Config{Speaker: &config.SpeakerConfig{Enabled: true}}
|
||||
|
||||
wireSpeaker(srv, nil, cfg)
|
||||
|
||||
if srv.EnrollSpeakerFn != nil || srv.ListSpeakersFn != nil || srv.ForgetSpeakerFn != nil {
|
||||
t.Error("speaker methods were wired with nothing to embed with")
|
||||
}
|
||||
}
|
||||
|
||||
// The gate is Recognizes(), so a disabled block with a model path is off too.
|
||||
func TestSpeakerStaysOffWhenDisabled(t *testing.T) {
|
||||
srv := &ipc.Server{}
|
||||
cfg := &config.Config{Speaker: &config.SpeakerConfig{ModelPath: "/nope/ecapa.onnx"}}
|
||||
|
||||
wireSpeaker(srv, nil, cfg)
|
||||
|
||||
if srv.EnrollSpeakerFn != nil {
|
||||
t.Error("speaker methods were wired for a disabled block")
|
||||
}
|
||||
}
|
||||
|
||||
// ErrDisabled is "this capability is off", not "core broke". It used to fall
|
||||
// through speakerErr's default and reach the surface as an opaque failure.
|
||||
func TestSpeakerErrMapsDisabledToUnknownMethod(t *testing.T) {
|
||||
if got := speakerErr(speaker.ErrDisabled); !errors.Is(got, ipc.ErrUnknownMethod) {
|
||||
t.Errorf("speakerErr(ErrDisabled) = %v, want ErrUnknownMethod", got)
|
||||
}
|
||||
if got := speakerErr(speaker.ErrNotFound); !errors.Is(got, ipc.ErrNoFact) {
|
||||
t.Errorf("speakerErr(ErrNotFound) = %v, want ErrNoFact", got)
|
||||
}
|
||||
if got := speakerErr(nil); got != nil {
|
||||
t.Errorf("speakerErr(nil) = %v", got)
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "act_degraded",
|
||||
"description": "The act path against a Praxis that goes down and comes back. This is the case the harness promised and did not have: the other two scenarios never produce an act, so the ecosystem fakes saw zero requests and the fault lever was inert. Here a scripted act reaches an enabled allowlist row, the row is a Praxis verb, and the same utterance runs healthy, then at 503, then healthy again. The degraded turn must say she cannot reach it and must not send anything at him off the back of it.",
|
||||
"start": "2026-08-01T09:00:00+03:00",
|
||||
"praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"morning_medicine\"}]",
|
||||
"tools": [{ "name": "list_attention" }],
|
||||
"script": [
|
||||
{
|
||||
"match": "требует внимания",
|
||||
"route": "[{\"intent\":\"act\",\"verb\":\"list_attention\"}]"
|
||||
},
|
||||
{
|
||||
"match": "",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]",
|
||||
"reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"at": "09:00",
|
||||
"note": "a healthy act reaches Praxis and speaks what it found",
|
||||
"say": "что требует внимания?",
|
||||
"expect_reply_contains": ["medicine not taken"],
|
||||
"expect_called": ["/api/v1/tools/attention"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "09:05",
|
||||
"note": "the ecosystem goes down",
|
||||
"fault": 503
|
||||
},
|
||||
{
|
||||
"at": "09:10",
|
||||
"note": "the same act against a 503. She says she cannot reach it. She does not invent an answer and she does not push anything at him.",
|
||||
"say": "что требует внимания?",
|
||||
"expect_reply_contains": ["не могу сейчас узнать"],
|
||||
"expect_reply_lacks": ["medicine not taken"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "09:15",
|
||||
"note": "a tick while the ecosystem is down touches nothing out there — the proactive loop has no business calling Praxis",
|
||||
"tick": true,
|
||||
"expect_not_called": ["/api/v1"],
|
||||
"expect_no_send": true,
|
||||
"expect_no_events": true
|
||||
},
|
||||
{
|
||||
"at": "09:20",
|
||||
"note": "recovery: the same act works again, so the degraded turn left no sticky state",
|
||||
"clear_fault": true,
|
||||
"say": "что требует внимания?",
|
||||
"expect_reply_contains": ["medicine not taken"],
|
||||
"expect_no_send": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "evening_degraded",
|
||||
"description": "The tier-2 pipeline case #288 deferred here, plus degraded mode. A golden WAV goes in at the microphone end and comes out as a written fact, and then the ecosystem starts answering 503 and the proactive loop has to stay quiet instead of falling over. The audio step asserts the PIPELINE — mic to STT seam to router to store to TTS — not whisper's accuracy; cmd/mavsttd/golden_test.go owns accuracy.",
|
||||
"start": "2026-08-01T21:00:00+03:00",
|
||||
"praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"evening_medicine\"}]",
|
||||
"script": [
|
||||
{
|
||||
"match": "выпил воды",
|
||||
"route": "[{\"intent\":\"fact\",\"key\":\"water\",\"value\":\"выпил\"}]"
|
||||
},
|
||||
{
|
||||
"match": "записала факт: water",
|
||||
"reply": "{\"response\":\"Записала, что ты выпил воды.\",\"mood\":\"neutral\"}"
|
||||
},
|
||||
{
|
||||
"match": "",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]",
|
||||
"reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"at": "21:00",
|
||||
"note": "he speaks. The whole voice path runs: push-to-talk, the STT seam parked with the golden transcript, the real router, the real store write, the phrasing contract.",
|
||||
"audio": "ru_fact",
|
||||
"expect_reply_contains": ["записала"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш"],
|
||||
"expect_events": ["water"]
|
||||
},
|
||||
{
|
||||
"at": "21:05",
|
||||
"note": "a healthy tick with him just having spoken stays silent",
|
||||
"tick": true,
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "21:10",
|
||||
"note": "the ecosystem goes down",
|
||||
"fault": 503
|
||||
},
|
||||
{
|
||||
"at": "21:15",
|
||||
"note": "a tick against a dead ecosystem must degrade, not send half a thought",
|
||||
"tick": true,
|
||||
"expect_no_send": true,
|
||||
"expect_no_events": true
|
||||
},
|
||||
{
|
||||
"at": "21:20",
|
||||
"note": "intake keeps working while the ecosystem is down — a write does not depend on it",
|
||||
"arrive": {
|
||||
"source": "rss:tech",
|
||||
"note": { "text": "Патч 6.19.1 [tech]\nисправления\nhttps://example.org/b" }
|
||||
},
|
||||
"expect_events": ["rss:tech"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "21:25",
|
||||
"note": "recovery",
|
||||
"clear_fault": true,
|
||||
"tick": true,
|
||||
"expect_no_send": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "morning_missed",
|
||||
"description": "The scenario from Vikunja #284's description, replayed. He appears at 08:30, things arrive through the morning while he is at the desk, and at 08:50 he asks what he missed. The assertions are as much about what did NOT happen — nothing was sent at him unprompted — as about what she said.",
|
||||
"start": "2026-08-01T08:30:00+03:00",
|
||||
"praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"morning_medicine\"}]",
|
||||
"script": [
|
||||
{
|
||||
"match": "выпил воды",
|
||||
"route": "[{\"intent\":\"fact\",\"key\":\"water\",\"value\":\"выпил\"}]"
|
||||
},
|
||||
{
|
||||
"match": "записала факт: water",
|
||||
"reply": "{\"response\":\"Записала, что ты выпил воды.\",\"mood\":\"neutral\"}"
|
||||
},
|
||||
{
|
||||
"match": "что я пропустил",
|
||||
"route": "[{\"intent\":\"query\",\"text\":\"что я пропустил\"}]"
|
||||
},
|
||||
{
|
||||
"match": "",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]",
|
||||
"reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"at": "08:30",
|
||||
"note": "he appears at the desk",
|
||||
"signal": { "key": "desk_active", "value": "true", "source": "infer:hyprland" },
|
||||
"expect_events": ["infer:hyprland"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "08:32",
|
||||
"note": "a feed item arrives, published half an hour ago",
|
||||
"arrive": {
|
||||
"source": "rss:tech",
|
||||
"as_of": "08:02",
|
||||
"note": { "text": "Вышло ядро 6.19 [tech]\nкраткое содержание\nhttps://example.org/a" }
|
||||
},
|
||||
"expect_events": ["rss:tech"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "08:35",
|
||||
"note": "the mail reader extracts a candidate — a candidate is never spoken",
|
||||
"arrive": {
|
||||
"source": "email:inbox",
|
||||
"task": { "text": "продлить домен", "evidence": "Домен истекает через 7 дней" }
|
||||
},
|
||||
"expect_events": ["email:inbox", "продлить домен"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "08:40",
|
||||
"note": "the work calendar signal — a relayed notification, at the ambient path's own 0.6 rather than an observation she made herself. That is the branch factPriority takes, so the journal must file it low.",
|
||||
"arrive": {
|
||||
"source": "ambient:notif",
|
||||
"fact": {
|
||||
"key": "calendar_event_20260801_планёрка",
|
||||
"value": "10:00-11:00 планёрка",
|
||||
"confidence": 0.6
|
||||
}
|
||||
},
|
||||
"expect_events": ["планёрка", "ambient:notif/fact pri=low"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "08:45",
|
||||
"note": "a tick with him present and nothing wrong must stay silent",
|
||||
"tick": true,
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "08:50",
|
||||
"note": "he asks. The query path answers from local recall only: nothing stored clears the score gate, so she refuses rather than inventing a morning summary, and the replier is never reached. That refusal is the no-hallucination floor and this step pins it. Note what the persona check here is and is not: the reply is a constant in the Go source, so expect_reply_lacks pins that constant, not anything the model wrote. The step below is the one that reads model output.",
|
||||
"say": "что я пропустил?",
|
||||
"expect_reply_contains": ["не знаю"],
|
||||
"expect_reply_lacks": ["рад ", "милый", "ваш"]
|
||||
},
|
||||
{
|
||||
"at": "08:55",
|
||||
"note": "stating a fact writes it and says so, in the feminine. This reply comes back through the replier from the scripted model, so the persona check is against generated text rather than a constant. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\", and the earlier check on the comma alone passed on \"записал что ты выпил воды\".",
|
||||
"say": "я выпил воды",
|
||||
"expect_reply_contains": ["записала"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый"],
|
||||
"expect_events": ["water"]
|
||||
},
|
||||
{
|
||||
"at": "09:00",
|
||||
"note": "a second tick, still nothing unprompted",
|
||||
"tick": true,
|
||||
"expect_no_send": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+48
-9
@@ -19,6 +19,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
@@ -417,6 +418,8 @@ func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop
|
||||
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
||||
// One announcement per tick at most, whatever the scan turned up. The
|
||||
// rest are on /routines; they are not lost, they are just not shouted.
|
||||
// Nor are they queued: the row now exists, so no later tick re-detects
|
||||
// them and they are never announced. See announceProposal.
|
||||
if announced {
|
||||
continue
|
||||
}
|
||||
@@ -437,6 +440,21 @@ func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop
|
||||
// re-detect it and nothing queues up behind it. A missed announcement means
|
||||
// he reads it on /routines instead, which is the whole point of the page.
|
||||
//
|
||||
// What the cooldown is and is not. detectAndPropose returns non-nil only for a
|
||||
// newly created row, so a pair gets exactly one chance to be spoken: the tick
|
||||
// that first proposes it. Combined with one announcement per tick, the first
|
||||
// tick over a populated history announces one pattern and permanently silences
|
||||
// every other pattern found in the same pass. That is the intent, not an
|
||||
// oversight — an inferred routine is not worth a second attempt at his
|
||||
// attention, and /routines lists all of them. So the cooldown does not drain a
|
||||
// backlog. It only spaces announcements of genuinely new pairs discovered on
|
||||
// later ticks. If it should ever become "one per day until each is mentioned",
|
||||
// that needs a queue rather than this counter.
|
||||
//
|
||||
// Cooldown gets its default here as well as in applyDefaults. That is
|
||||
// deliberate: a tickLoop assembled directly in a test never goes through Load,
|
||||
// and an unspaced announcer is not what those tests mean to exercise.
|
||||
//
|
||||
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
|
||||
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
|
||||
// an inferred routine cannot arrive worded as something Maven never observed.
|
||||
@@ -808,8 +826,10 @@ func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||
}
|
||||
for _, f := range facts {
|
||||
events = append(events, morning.PlanEntry{
|
||||
At: f.Ts,
|
||||
Text: f.Value,
|
||||
At: f.Ts,
|
||||
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
|
||||
// fact value carries would say it twice.
|
||||
Text: calendar.FactSummary(f.Value),
|
||||
Kind: morning.PlanEvent,
|
||||
// Provenance below a calendar read (an ambient relay, #126) is
|
||||
// hedged rather than recited as fact.
|
||||
@@ -818,12 +838,12 @@ func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||
}
|
||||
|
||||
var reminders []morning.PlanEntry
|
||||
rems, err := t.store.ListReminders(ctx, dayPlanMaxReminders)
|
||||
rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd)
|
||||
if err != nil {
|
||||
log.Printf("tick: day plan: list reminders: %v", err)
|
||||
log.Printf("tick: day plan: pending reminders: %v", err)
|
||||
}
|
||||
for _, r := range rems {
|
||||
if r.Status != "pending" {
|
||||
if r.Status != store.ReminderPending {
|
||||
continue
|
||||
}
|
||||
fire := r.NextFireTs
|
||||
@@ -856,10 +876,6 @@ func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||
return out
|
||||
}
|
||||
|
||||
// dayPlanMaxReminders bounds the reminder scan. The plan covers one day; a
|
||||
// pending queue longer than this is a bug elsewhere, not a plan to recite.
|
||||
const dayPlanMaxReminders = 500
|
||||
|
||||
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
||||
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
||||
// rule:
|
||||
@@ -941,6 +957,19 @@ type daemonAPI struct {
|
||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||
chatFn func(ctx context.Context, text string) string
|
||||
getMCPServers func() []ipc.MCPServerStatus
|
||||
getEvents func(n int) []ipc.IntakeEvent
|
||||
}
|
||||
|
||||
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
|
||||
// error, when no bus was wired: "nothing has arrived" and "the journal is off"
|
||||
// look the same to a reader on purpose, because neither is a fault and the
|
||||
// page renders both as an empty table.
|
||||
func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, error) {
|
||||
if d.getEvents == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return d.getEvents(n), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
@@ -950,6 +979,16 @@ func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
return d.chatFn(ctx, text), nil
|
||||
}
|
||||
|
||||
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
||||
// Empty, not an error, when the mcp block is absent: "not configured" is the
|
||||
// default state and the web surface renders it as such.
|
||||
func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) {
|
||||
if d.getMCPServers == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return d.getMCPServers(), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
||||
trace := d.getTrace()
|
||||
if trace == nil {
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// mavend/vision.go — core's half of image understanding (Vikunja #252,
|
||||
// docs/plans/07-vision.md).
|
||||
//
|
||||
// The split: any surface that can receive a picture (mavweb upload, a Telegram
|
||||
// photo through mavpoll, a path he names) hands the bytes to core over
|
||||
// ipc.MethodDescribeImage. Core stores them content-addressed under
|
||||
// media.dir, prepares a downscaled JPEG, and asks a local vision server what it
|
||||
// is. The description comes back as words; nothing about the image is echoed.
|
||||
//
|
||||
// Off unless configured: no `media` block ⇒ nowhere to keep the bytes, so the
|
||||
// method does not exist and a surface cannot make Maven accept a photo by
|
||||
// merely sending one. A `media` block with no `vision` block is a real state,
|
||||
// the one this box is in today: the store is wired, the method exists, the
|
||||
// bytes are kept and the reply says she cannot read the picture yet. That reply
|
||||
// is re-runnable by id on the day a vision model lands, which is the reason to
|
||||
// keep the bytes at all. Saving the description as a note needs more than the
|
||||
// read rung — see the scope check on auth.ImageNoteSource.
|
||||
//
|
||||
// Two things this file deliberately does not do:
|
||||
//
|
||||
// - No cloud vision call, ever. internal/vision refuses a non-private
|
||||
// endpoint at construction; there is no config shape here that could reach
|
||||
// an upstream API even if someone wanted one.
|
||||
// - No automatic memory. SaveNote is opt-in per call. Glancing at a screenshot
|
||||
// is not the same act as remembering it, and a 1.7B-class VLM's guess about
|
||||
// a photo is not a fact worth carrying around.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/media"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/vision"
|
||||
)
|
||||
|
||||
// prunePeriod — how often stored blobs are checked against media.retention.
|
||||
// Hourly is far more often than needed for a 7-day retention and costs a
|
||||
// directory walk over a handful of sidecars; the point is that the promise is
|
||||
// kept by a loop that runs, not by an operator remembering a cron.
|
||||
const prunePeriod = time.Hour
|
||||
|
||||
// mediaKeeper — the blob store plus the loop that enforces its retention. The
|
||||
// two are one object because a store without the loop is a directory that grows
|
||||
// forever, and shipping that would break the only interesting promise this
|
||||
// capability makes.
|
||||
type mediaKeeper struct {
|
||||
store *media.Store
|
||||
}
|
||||
|
||||
// openMediaStore builds the blob store from config, or returns nil when media is
|
||||
// not configured. A relative dir resolves against StateDir, the same rule the db
|
||||
// and socket paths follow.
|
||||
func openMediaStore(cfg *config.Config) *mediaKeeper {
|
||||
dir := cfg.Media.StoreDir()
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
if !filepath.IsAbs(dir) && cfg.StateDir != "" {
|
||||
dir = filepath.Join(cfg.StateDir, dir)
|
||||
}
|
||||
st, err := media.OpenWithBudget(dir, cfg.Media.MaxBytes, cfg.Media.MaxTotalBytes,
|
||||
time.Duration(cfg.Media.Retention))
|
||||
if err != nil {
|
||||
log.Printf("media: %v — image and audio intake disabled", err)
|
||||
return nil
|
||||
}
|
||||
log.Printf("media: blob store at %s, retention %s, %d of %d bytes used",
|
||||
st.Dir(), st.Retention(), st.Total(), st.Budget())
|
||||
return &mediaKeeper{store: st}
|
||||
}
|
||||
|
||||
// runPrune deletes over-retention blobs on a loop until ctx ends. It prunes once
|
||||
// immediately, so a daemon restarted after a long downtime does not sit on a
|
||||
// month of stale recordings until the first tick.
|
||||
func (k *mediaKeeper) runPrune(ctx context.Context) {
|
||||
prune := func() {
|
||||
n, err := k.store.Prune()
|
||||
if err != nil {
|
||||
log.Printf("media: prune: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("media: pruned %d blob(s) older than %s", n, k.store.Retention())
|
||||
}
|
||||
}
|
||||
prune()
|
||||
t := time.NewTicker(prunePeriod)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
prune()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// visionIntake — one image at a time: store, prepare, describe, optionally note.
|
||||
type visionIntake struct {
|
||||
in *vision.Intake
|
||||
st *store.Store
|
||||
emb router.Embedder
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// newVisionIntake returns nil when there is nothing to wire. keeper == nil means
|
||||
// no media block, which disables the method outright; a missing or disabled
|
||||
// vision block still wires the method, because storing an image and answering
|
||||
// "I can't look at it yet" is more useful than pretending the surface does not
|
||||
// exist — and it is exactly the state this box is in until a vision model is on
|
||||
// disk.
|
||||
func newVisionIntake(keeper *mediaKeeper, st *store.Store, emb router.Embedder, cfg *config.Config) *visionIntake {
|
||||
if keeper == nil {
|
||||
return nil
|
||||
}
|
||||
vc := cfg.Vision
|
||||
maxDim := 0
|
||||
var provider vision.Provider = vision.Disabled{}
|
||||
if vc.LooksAtImages() {
|
||||
p, err := vision.NewLocal(vision.Config{
|
||||
Endpoint: vc.Endpoint,
|
||||
Model: vc.Model,
|
||||
Timeout: time.Duration(vc.Timeout),
|
||||
MaxTokens: vc.MaxTokens,
|
||||
Prompt: vc.Prompt,
|
||||
})
|
||||
if err != nil {
|
||||
// A public endpoint, a hostname, a bad URL. Logged once here rather
|
||||
// than failing every turn, and the store still works.
|
||||
log.Printf("vision: %v — she can store images but not describe them", err)
|
||||
} else {
|
||||
provider = p
|
||||
maxDim = vc.MaxDim
|
||||
log.Printf("vision: enabled against %s", p.Endpoint())
|
||||
}
|
||||
} else {
|
||||
log.Printf("vision: not configured — images are stored, not described")
|
||||
}
|
||||
return &visionIntake{
|
||||
in: vision.NewIntake(keeper.store, provider, maxDim),
|
||||
st: st,
|
||||
emb: emb,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// describe handles one ipc.MethodDescribeImage call.
|
||||
//
|
||||
// A description failure is NOT an error out of this method when the bytes were
|
||||
// stored: the caller gets the id and an empty description, which is honest ("it
|
||||
// is kept, I cannot read it yet") and re-runnable. A failure to store, or bytes
|
||||
// that are not an image at all, is an error — there is nothing to come back to.
|
||||
func (v *visionIntake) describe(ctx context.Context, req ipc.DescribeImageReq) (ipc.DescribeImageResp, error) {
|
||||
if len(req.Data) == 0 && req.ID == "" {
|
||||
return ipc.DescribeImageResp{}, fmt.Errorf("describe image: neither data nor id")
|
||||
}
|
||||
if len(req.Data) > 0 && req.ID != "" {
|
||||
// The contract says exactly one. Taking the ID branch and dropping the
|
||||
// bytes silently is the worst of the three possible answers: the caller
|
||||
// believes it sent a new image and nothing says otherwise.
|
||||
return ipc.DescribeImageResp{}, fmt.Errorf("describe image: both data and id given, send one")
|
||||
}
|
||||
|
||||
var (
|
||||
res vision.Result
|
||||
err error
|
||||
)
|
||||
if req.ID != "" {
|
||||
res, err = v.in.Rerun(ctx, req.ID, req.Question)
|
||||
} else {
|
||||
res, err = v.in.Accept(ctx, req.Data, sourceOrDefault(req.Source), req.Question)
|
||||
}
|
||||
if res.Blob.ID == "" {
|
||||
// Nothing was stored: bad format, over the size cap, unwritable dir.
|
||||
return ipc.DescribeImageResp{}, fmt.Errorf("describe image: %w", err)
|
||||
}
|
||||
|
||||
resp := ipc.DescribeImageResp{
|
||||
ID: res.Blob.ID,
|
||||
Description: res.Description,
|
||||
Width: res.Image.Width,
|
||||
Height: res.Image.Height,
|
||||
}
|
||||
if err != nil {
|
||||
// Bytes are safe, words are not available. The log names the blob and the
|
||||
// reason; it never names what was in the picture.
|
||||
if errors.Is(err, vision.ErrDisabled) {
|
||||
log.Printf("vision: stored %s, no vision model configured", res.Blob)
|
||||
} else {
|
||||
log.Printf("vision: stored %s, describe failed: %v", res.Blob, err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if req.SaveNote {
|
||||
id, werr := v.writeNote(ctx, res)
|
||||
if werr != nil {
|
||||
// The description is still returned: losing the note is worse as a
|
||||
// silent failure than as a log line next to a successful answer.
|
||||
log.Printf("vision: note write for %s failed: %v", res.Blob, werr)
|
||||
} else {
|
||||
resp.NoteID = id
|
||||
}
|
||||
}
|
||||
log.Printf("vision: described %s (%dx%d)", res.Blob, res.Image.Width, res.Image.Height)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// noteMarker prefixes a stored description. Without it the note reads exactly
|
||||
// like something he told her, and it is not: it is a small VLM's guess about a
|
||||
// picture, embedded and recalled as if it were his own words. Four characters
|
||||
// of provenance in the text are cheaper than believing it later.
|
||||
const noteMarker = "Со снимка: "
|
||||
|
||||
// writeNote stores the description as an ordinary note so it is recallable. The
|
||||
// note carries the blob id in its source, which is the only link back to the
|
||||
// bytes — the note text is words about the picture, never the picture.
|
||||
func (v *visionIntake) writeNote(ctx context.Context, res vision.Result) (int64, error) {
|
||||
var vec []float32
|
||||
if v.emb != nil {
|
||||
// EmbedPassage, not Embed: a description is text being searched FOR, and
|
||||
// the e5 embedder is asymmetric. Backwards here makes it unfindable by
|
||||
// the question that should have matched it.
|
||||
var err error
|
||||
vec, err = router.EmbedPassage(ctx, v.emb, res.Description)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("embed: %w", err)
|
||||
}
|
||||
}
|
||||
source := "media:image:" + res.Blob.ID[:12]
|
||||
return v.st.WriteNote(ctx, v.now(), noteMarker+res.Description, vec, source)
|
||||
}
|
||||
|
||||
// sourceOrDefault labels a blob whose sender did not say where it came from.
|
||||
func sourceOrDefault(s string) string {
|
||||
if s == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// wireVision installs the IPC hook and starts the retention loop, or leaves the
|
||||
// hook nil so ipc.MethodDescribeImage reports ErrUnknownMethod. Called on both
|
||||
// startup paths (unlocked boot and passkey unlock) so vision behaves the same
|
||||
// either way.
|
||||
//
|
||||
// Returns the media keeper so the meeting recorder can share it: one blob store
|
||||
// with one retention loop holds both the images and the audio, which is the
|
||||
// whole point of internal/media being a shared package. nil ⇒ no media block,
|
||||
// and neither capability exists.
|
||||
func wireVision(ctx context.Context, wg *sync.WaitGroup, srv *ipc.Server, st *store.Store, emb router.Embedder, cfg *config.Config) *mediaKeeper {
|
||||
keeper := openMediaStore(cfg)
|
||||
if keeper == nil {
|
||||
return nil
|
||||
}
|
||||
// In the daemon's WaitGroup like every other loop in run: a prune deletes
|
||||
// files, and shutting down in the middle of one was the single loop nobody
|
||||
// waited for.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
keeper.runPrune(ctx)
|
||||
}()
|
||||
|
||||
vi := newVisionIntake(keeper, st, emb, cfg)
|
||||
if vi == nil {
|
||||
return keeper
|
||||
}
|
||||
srv.DescribeImageFn = vi.describe
|
||||
return keeper
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/media"
|
||||
"github.com/kami/maven/internal/vision"
|
||||
)
|
||||
|
||||
func testIntake(t *testing.T) *visionIntake {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
blobs, err := media.Open(t.TempDir(), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &visionIntake{
|
||||
in: vision.NewIntake(blobs, vision.Disabled{}, 0),
|
||||
st: st,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// The contract says exactly one of Data or ID. Taking the ID branch and
|
||||
// dropping the bytes silently is the worst of the three possible answers: the
|
||||
// caller believes it sent a new image and nothing says otherwise.
|
||||
func TestDescribeRefusesBothDataAndID(t *testing.T) {
|
||||
v := testIntake(t)
|
||||
_, err := v.describe(context.Background(), ipc.DescribeImageReq{
|
||||
Data: []byte("bytes"), ID: strings.Repeat("a", 64),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("both data and id must be refused")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "send one") {
|
||||
t.Fatalf("err = %v, want it to name the contract", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Vision being off does not remove the method: the bytes are stored and the
|
||||
// answer says she cannot read the picture yet, which is re-runnable by id. That
|
||||
// is the state this box is in today, and three doc comments used to claim the
|
||||
// opposite.
|
||||
func TestVisionOffStillStores(t *testing.T) {
|
||||
v := testIntake(t)
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 4, 4))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := v.describe(context.Background(), ipc.DescribeImageReq{Data: buf.Bytes(), Source: "web:upload"})
|
||||
if err != nil {
|
||||
t.Fatalf("storing must succeed even with no vision model: %v", err)
|
||||
}
|
||||
if len(resp.ID) != 64 {
|
||||
t.Fatalf("no blob id came back: %+v", resp)
|
||||
}
|
||||
if resp.Description != "" {
|
||||
t.Errorf("description = %q, want none", resp.Description)
|
||||
}
|
||||
// And with no media block at all the method does not exist.
|
||||
if vi := newVisionIntake(nil, nil, nil, &config.Config{}); vi != nil {
|
||||
t.Fatal("no media block must leave the method nonexistent")
|
||||
}
|
||||
}
|
||||
+46
-14
@@ -92,6 +92,17 @@ type reactiveHandler struct {
|
||||
// instead of "ничего нового", which are different truths.
|
||||
feedsOn bool
|
||||
|
||||
// home — the Home Assistant client (Vikunja #256). nil ⇒ the house is not
|
||||
// configured, which is the default: no `smarthome` block, no reads, no
|
||||
// switches. Control does not go through this field — it goes through the
|
||||
// act allowlist and tool.Executor, like every other mutating act.
|
||||
home *homeWiring
|
||||
|
||||
// netscan — the LAN scanner (Vikunja #257). nil ⇒ off, which is the
|
||||
// default. A scan is a read, so it has no allowlist row; what keeps it
|
||||
// safe is that its range comes from config and from nowhere else.
|
||||
netscan *netWiring
|
||||
|
||||
weatherProvider weather.Provider
|
||||
weatherLocation string // default location for weather queries
|
||||
|
||||
@@ -161,7 +172,7 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
|
||||
|
||||
// 2-5. the shared turn pipeline (confirm → clarify → route → dialogue →
|
||||
// action → replier), identical to the text path.
|
||||
replyText := h.runTurn(ctx, text)
|
||||
replyText := h.runTurn(ctx, text, sourceVoice)
|
||||
|
||||
// 6. tts — synthesise the reply text; return to the voice server which
|
||||
// ships it back on the conn.
|
||||
@@ -173,44 +184,65 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
|
||||
// HandlePushToTalk so text channels share the same routing logic.
|
||||
func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
|
||||
log.Printf("voice: handleText: %q", text)
|
||||
return h.runTurn(ctx, text)
|
||||
return h.runTurn(ctx, text, sourceText)
|
||||
}
|
||||
|
||||
// turnSource — which channel this utterance arrived on, in the same provenance
|
||||
// vocabulary facts use (internal/event). It is threaded through runTurn because
|
||||
// a turn can write a fact, and a fact that lies about where it came from is
|
||||
// worse than no fact: provenance is the first column read when asking why a
|
||||
// daemon-wide setting is the way it is.
|
||||
type turnSource string
|
||||
|
||||
const (
|
||||
sourceVoice turnSource = "tap:voice" // HandlePushToTalk, a real microphone
|
||||
sourceText turnSource = "tap:text" // handleText: mavweb /api/chat, telegram
|
||||
)
|
||||
|
||||
// runTurn — the reactive turn pipeline shared by the voice and text entry
|
||||
// points: confirm answer → expired-clarify notice → clarify answer → quiet
|
||||
// points: expired-clarify notice → confirm answer → clarify answer → quiet
|
||||
// toggle → route → dialogue merge → clarify question → action → replier.
|
||||
// Takes the already-transcribed utterance, returns the reply text; the voice
|
||||
// path wraps it in stt/tts, the text path returns it as-is.
|
||||
//
|
||||
// The ordering is load-bearing — see the step comments.
|
||||
func (h *reactiveHandler) runTurn(ctx context.Context, text string) string {
|
||||
// 1. confirm turn — if a destructive act is parked, this utterance is its
|
||||
// y/n answer, not a fresh command. Handled before routing so "да" doesn't
|
||||
// get classified as some other intent.
|
||||
if reply, handled := h.resolveConfirm(ctx, text); handled {
|
||||
return reply
|
||||
}
|
||||
|
||||
// 2. expired clarify — a question was parked but its TTL ran out, so the
|
||||
func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) string {
|
||||
// 1. expired clarify — a question was parked but its TTL ran out, so the
|
||||
// request behind it is gone. Say that out loud (see clarify.go) and carry
|
||||
// on: these words are still routed as a fresh utterance below, with the
|
||||
// notice glued in front of whatever the fresh routing answers. Checked
|
||||
// BEFORE the answer path: reading a parked question drops an expired one.
|
||||
//
|
||||
// Taken before the confirm check, not after, because a confirm turn returns
|
||||
// early. He can be asked a question, walk off, come back and say "да" to a
|
||||
// confirm that is still parked; computing the notice after that return meant
|
||||
// he answered the confirm and never heard that the older request was let go.
|
||||
expiredNotice := h.clarifyExpiredNotice()
|
||||
|
||||
// 2. confirm turn — if a destructive act is parked, this utterance is its
|
||||
// y/n answer, not a fresh command. Handled before routing so "да" doesn't
|
||||
// get classified as some other intent.
|
||||
if reply, handled := h.resolveConfirm(ctx, text); handled {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
// 3. clarify answer — if she asked a live question last turn, this
|
||||
// utterance is its answer, not a fresh command. After the confirm check: a
|
||||
// y/n gate is armed by her own prompt and is the narrower claim on the
|
||||
// utterance.
|
||||
// A live question and an expired one cannot both exist for one dialogue id,
|
||||
// so the notice is empty here in practice. withNotice anyway: every exit
|
||||
// from runTurn carries it, and that is what stops the next one from
|
||||
// forgetting.
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, text); handled {
|
||||
return reply
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
// 4. quiet-hours toggle — keyword match, not classifier-dependent.
|
||||
// "тихий режим" / "quiet on" would route through the classifier
|
||||
// unreliably (it's a command, not a free-form query), so we match it
|
||||
// before routing. Same pattern as the confirm turn above.
|
||||
if reply, handled := h.resolveQuietToggle(ctx, text); handled {
|
||||
if reply, handled := h.resolveQuietToggle(ctx, text, src); handled {
|
||||
return withNotice(expiredNotice, reply)
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,22 @@ type voiceWiring struct {
|
||||
// mavsttd / mavttsd don't keep a stale conn into a restarting daemon.
|
||||
sttClient *worker.Client
|
||||
ttsClient *worker.Client
|
||||
// transcriber — the STT in use, exposed so the meeting recorder
|
||||
// (cmd/mavend/capture.go) can reuse it. Maven has exactly one STT and does
|
||||
// not grow a second one for capture: this is the same whisper.cpp worker the
|
||||
// voice path talks to.
|
||||
transcriber stt.Transcriber
|
||||
// mcp — the MCP client, nil unless the `mcp` block configures an enabled
|
||||
// server (Vikunja #251). Its tools land in the same allowlist as every
|
||||
// other act, so nothing else here has to know about it.
|
||||
mcp *mcpWiring
|
||||
// home — the Home Assistant client, nil unless the `smarthome` block is
|
||||
// enabled (Vikunja #256). Its devices land in the same allowlist as every
|
||||
// other act, so nothing else here has to know about it.
|
||||
home *homeWiring
|
||||
// netscan — the LAN scanner, nil unless the `netscan` block is enabled
|
||||
// (Vikunja #257).
|
||||
netscan *netWiring
|
||||
}
|
||||
|
||||
// close releases the listener + worker conns. Safe to call on nil (when
|
||||
@@ -60,6 +76,7 @@ func (w *voiceWiring) close() {
|
||||
if w.ttsClient != nil {
|
||||
_ = w.ttsClient.Close()
|
||||
}
|
||||
w.mcp.close()
|
||||
}
|
||||
|
||||
// wireVoice builds the audio path from cfg + a CoreAPI + a router. Returns
|
||||
@@ -87,6 +104,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
} else {
|
||||
transcriber = stt.NewStub()
|
||||
}
|
||||
w.transcriber = transcriber
|
||||
|
||||
// ----- tts (Stub in-process OR Remote) -----
|
||||
var synthesizer tts.Synthesizer
|
||||
@@ -131,6 +149,24 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
// daemon restart.
|
||||
seedTools(coreAPI, cfg.Voice.Tools)
|
||||
exec := tool.NewExecutor(coreAPI, time.Duration(cfg.Voice.ToolTimeout))
|
||||
// MCP servers (Vikunja #251): discovery PROPOSES tools into the same
|
||||
// allowlist, so an MCP tool is enabled by hand on /tools like any other and
|
||||
// runs through the same confirm turn. Off unless the `mcp` block configures
|
||||
// an enabled server.
|
||||
w.mcp = wireMCP(cfg, dataStore)
|
||||
if w.mcp != nil {
|
||||
exec = exec.WithMCP(w.mcp.caller())
|
||||
}
|
||||
// The house (Vikunja #256): same story as MCP. Discovery PROPOSES a row per
|
||||
// controllable device, always destructive, and Kami enables the ones he
|
||||
// wants on /tools. Off unless the `smarthome` block is enabled.
|
||||
w.home = wireSmartHome(cfg, dataStore)
|
||||
if w.home != nil {
|
||||
exec = exec.WithHome(w.home.caller())
|
||||
}
|
||||
// The LAN scanner (Vikunja #257): a read, bounded to the configured
|
||||
// subnets and rate-limited. Off unless the `netscan` block is enabled.
|
||||
w.netscan = wireNetScan(cfg, coreAPI)
|
||||
matcher := tool.NewMatcher(coreAPI)
|
||||
|
||||
// ----- weather provider (Open-Meteo when configured, Stub otherwise) -----
|
||||
@@ -214,6 +250,8 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
phraser: phr,
|
||||
now: time.Now,
|
||||
feedsOn: cfg.Feeds != nil,
|
||||
home: w.home,
|
||||
netscan: w.netscan,
|
||||
// nil unless `crawl.on_demand` is on: reading a page he names is a
|
||||
// capability, and capabilities are off unless configured.
|
||||
crawler: onDemandCrawler(cfg),
|
||||
|
||||
+30
-22
@@ -19,32 +19,40 @@ func isWeatherQuery(u string) bool {
|
||||
strings.Contains(lower, "temperature")
|
||||
}
|
||||
|
||||
// extractWeatherLocation parses a location from the utterance, or falls back
|
||||
// to the configured default. Very basic: just checks for known city names.
|
||||
// weatherCities — the city names an utterance may name explicitly, as
|
||||
// lowercase substrings mapped to the provider's spelling. This is a
|
||||
// convenience for "какая погода в Лондоне", NOT a source of default truth:
|
||||
// nothing here is used unless he actually said it.
|
||||
var weatherCities = map[string]string{
|
||||
"москв": "Moscow",
|
||||
"moscow": "Moscow",
|
||||
"питер": "Saint Petersburg",
|
||||
"spb": "Saint Petersburg",
|
||||
"петербур": "Saint Petersburg",
|
||||
"лондон": "London",
|
||||
"london": "London",
|
||||
"париж": "Paris",
|
||||
"paris": "Paris",
|
||||
"берлин": "Berlin",
|
||||
"berlin": "Berlin",
|
||||
"нью-йорк": "New York",
|
||||
"new york": "New York",
|
||||
}
|
||||
|
||||
// extractWeatherLocation returns the city he named, or the configured default
|
||||
// when he named none. It returns "" when he named none AND no default is
|
||||
// configured — the caller must then say it does not know.
|
||||
//
|
||||
// It used to return "Moscow" in that case. That is a made-up answer presented
|
||||
// as fact: reading out Moscow's temperature to someone who is not in Moscow is
|
||||
// wrong in exactly the way maven must never be wrong. voice.weather
|
||||
// .default_location is the only source of an unstated location.
|
||||
func extractWeatherLocation(u, defaultLoc string) string {
|
||||
lower := strings.ToLower(u)
|
||||
cities := map[string]string{
|
||||
"москв": "Moscow",
|
||||
"moscow": "Moscow",
|
||||
"питер": "Saint Petersburg",
|
||||
"spb": "Saint Petersburg",
|
||||
"петербур": "Saint Petersburg",
|
||||
"лондон": "London",
|
||||
"london": "London",
|
||||
"париж": "Paris",
|
||||
"paris": "Paris",
|
||||
"берлин": "Berlin",
|
||||
"berlin": "Berlin",
|
||||
"нью-йорк": "New York",
|
||||
"new york": "New York",
|
||||
}
|
||||
for substr, name := range cities {
|
||||
for substr, name := range weatherCities {
|
||||
if strings.Contains(lower, substr) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
if defaultLoc != "" {
|
||||
return defaultLoc
|
||||
}
|
||||
return "Moscow"
|
||||
return defaultLoc
|
||||
}
|
||||
|
||||
+90
-17
@@ -61,6 +61,11 @@ func run(args []string) error {
|
||||
mailbox := fs.String("mailbox", "INBOX", "mailbox to read, read-only")
|
||||
interval := fs.Duration("interval", 15*time.Minute, "how often to read the mailbox")
|
||||
lookback := fs.Duration("lookback", 72*time.Hour, "how far back to search on each poll")
|
||||
// -max and -interval are one decision, not two. Every non-bulk message in a
|
||||
// poll is one serialized llama-server call on core's side, and core gates
|
||||
// mail extraction behind voice turns (llm.Gate), so a large batch does not
|
||||
// mute Maven, it just takes a while. Raise -max only alongside whatever
|
||||
// bound core is running.
|
||||
max := fs.Int("max", 25, "most messages to fetch in one poll")
|
||||
timeout := fs.Duration("timeout", 30*time.Second, "IMAP network timeout")
|
||||
statePath := fs.String("state", "", "file remembering which UIDs were read (default: none — every poll re-reads the window)")
|
||||
@@ -125,11 +130,18 @@ func run(args []string) error {
|
||||
log.Printf("mavmaild: bye")
|
||||
return nil
|
||||
case <-t.C:
|
||||
// Core told us mail ingestion is not configured. Nothing will change
|
||||
// without a core restart, and a restart restarts us too, so the
|
||||
// daemon stays up and does nothing at all.
|
||||
//
|
||||
// It does NOT exit. The compose service inherits restart:
|
||||
// unless-stopped, which restarts a clean exit as readily as a crash,
|
||||
// so exiting here produced a loop: log in to IMAP, get refused by
|
||||
// core, exit, restart, log in again. Four IMAP logins an hour
|
||||
// against a mailbox that has nothing to give, and Gmail and Yandex
|
||||
// both rate-limit exactly that.
|
||||
if r.disabled {
|
||||
// Core told us mail ingestion is not configured. Nothing will change
|
||||
// without a core restart, and a restart restarts us too.
|
||||
log.Printf("mavmaild: core does not accept mail — idling")
|
||||
return nil
|
||||
continue
|
||||
}
|
||||
r.pollOnce(ctx, password)
|
||||
}
|
||||
@@ -153,10 +165,17 @@ type reader struct {
|
||||
timeout time.Duration
|
||||
state *seenState
|
||||
|
||||
// dial — connection seam for the tests; nil ⇒ implicit TLS.
|
||||
dial func(addr string, timeout time.Duration) (*email.Conn, error)
|
||||
// fetchMail — the read seam, nil ⇒ the real IMAP read. The tests replace
|
||||
// the whole read rather than the transport: internal/email keeps its dialer
|
||||
// unexported so that no code outside that package can point the reader at a
|
||||
// cleartext socket and hand it the password, and this daemon is code
|
||||
// outside that package.
|
||||
fetchMail func(password string) ([]email.Message, error)
|
||||
|
||||
// disabled — core answered ErrUnknownMethod, i.e. it has no email block.
|
||||
// Written in pollOnce and read in the ticker loop, both on the one
|
||||
// goroutine that run() drives, so it needs no atomic. If a second caller of
|
||||
// pollOnce ever appears, this becomes a race and has to change.
|
||||
disabled bool
|
||||
}
|
||||
|
||||
@@ -179,23 +198,25 @@ func (r *reader) pollOnce(ctx context.Context, password string) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if m.Junk {
|
||||
junk++
|
||||
// Marked seen without a model call: the header filter already decided,
|
||||
// and re-classifying it every quarter hour would be pure waste.
|
||||
r.state.mark(m.UID)
|
||||
continue
|
||||
}
|
||||
resp, err := r.core.IngestMail(ctx, ipc.IngestMailReq{
|
||||
req := ipc.IngestMailReq{
|
||||
Mailbox: r.mailbox,
|
||||
UID: m.UID,
|
||||
From: m.From,
|
||||
Subject: m.Subject,
|
||||
Date: m.Date,
|
||||
Body: m.Body,
|
||||
})
|
||||
}
|
||||
if m.Junk {
|
||||
junk++
|
||||
// Core is TOLD, which is what its wire doc says: it counts the bulk
|
||||
// message and answers Skipped without spending the model. The header
|
||||
// filter already decided, so no content is sent with the verdict —
|
||||
// nothing will read it.
|
||||
req = ipc.IngestMailReq{Mailbox: r.mailbox, UID: m.UID, Junk: true}
|
||||
}
|
||||
resp, err := r.core.IngestMail(ctx, req)
|
||||
if errors.Is(err, ipc.ErrUnknownMethod) {
|
||||
log.Printf("mavmaild: core has no email block configured — mail ingestion is off; stopping")
|
||||
log.Printf("mavmaild: core has no email block configured — mail ingestion is off; idling until a restart")
|
||||
r.disabled = true
|
||||
return
|
||||
}
|
||||
@@ -217,6 +238,9 @@ func (r *reader) pollOnce(ctx context.Context, password string) {
|
||||
// fetch reads the mailbox. Messages already in the seen-set are not fetched at
|
||||
// all, so a steady mailbox costs one SEARCH per poll and nothing else.
|
||||
func (r *reader) fetch(password string) ([]email.Message, error) {
|
||||
if r.fetchMail != nil {
|
||||
return r.fetchMail(password)
|
||||
}
|
||||
f := email.FetchSince{
|
||||
Addr: r.addr,
|
||||
User: r.user,
|
||||
@@ -225,8 +249,24 @@ func (r *reader) fetch(password string) ([]email.Message, error) {
|
||||
Since: time.Now().Add(-r.lookback),
|
||||
Max: r.max,
|
||||
Skip: r.state.seen,
|
||||
// Everything below the oldest searchable UID has aged out of the
|
||||
// lookback window and can never be read again. Retiring it is what keeps
|
||||
// one permanently failing message from pinning the high-water mark
|
||||
// forever. See seenState.retire.
|
||||
OnSearch: func(uids []uint32) {
|
||||
if len(uids) == 0 {
|
||||
return
|
||||
}
|
||||
low := uids[0]
|
||||
for _, u := range uids {
|
||||
if u < low {
|
||||
low = u
|
||||
}
|
||||
}
|
||||
r.state.retire(low)
|
||||
},
|
||||
}
|
||||
return f.RunWith(password, r.dial)
|
||||
return f.Run(password)
|
||||
}
|
||||
|
||||
// ---- seen state ------------------------------------------------------------
|
||||
@@ -242,6 +282,14 @@ func (r *reader) fetch(password string) ([]email.Message, error) {
|
||||
// UIDs are per-mailbox and monotonic, so the set is kept as a high-water mark
|
||||
// plus the stragglers above it. If the server ever changes UIDVALIDITY, UIDs
|
||||
// reset and the window is simply re-read once — dedupe absorbs it.
|
||||
//
|
||||
// The high-water mark only advances through a CONTIGUOUS run, so a UID that
|
||||
// never ingests successfully would pin it forever: everything above stays in
|
||||
// the explicit set, and save rewrites all of it every poll. A year of that is
|
||||
// a few hundred thousand entries written every quarter hour, which breaks
|
||||
// nothing loudly and is exactly why it is worth catching. retire is the answer:
|
||||
// a UID that has fallen out of the SEARCH SINCE window can never be fetched
|
||||
// again, so there is nothing left to wait for.
|
||||
type seenState struct {
|
||||
path string
|
||||
high uint32
|
||||
@@ -280,6 +328,31 @@ func (s *seenState) mark(uid uint32) {
|
||||
}
|
||||
}
|
||||
|
||||
// retire records that no UID below floor is reachable any more — they have
|
||||
// aged out of the lookback window, so no poll will ever fetch them. The
|
||||
// high-water mark can jump past the gap they were holding open, and the
|
||||
// stragglers below it leave the explicit set.
|
||||
//
|
||||
// It never moves backwards, so a UIDVALIDITY reset (UIDs restarting low) makes
|
||||
// this a no-op rather than a way to un-see a mailbox.
|
||||
func (s *seenState) retire(floor uint32) {
|
||||
if floor == 0 || floor-1 <= s.high {
|
||||
return
|
||||
}
|
||||
s.high = floor - 1
|
||||
for u := range s.set {
|
||||
if u <= s.high {
|
||||
delete(s.set, u)
|
||||
}
|
||||
}
|
||||
// The run above the new mark may now be contiguous with it.
|
||||
for s.set[s.high+1] {
|
||||
delete(s.set, s.high+1)
|
||||
s.high++
|
||||
}
|
||||
s.dirty = true
|
||||
}
|
||||
|
||||
func (s *seenState) load() error {
|
||||
if s.path == "" {
|
||||
return nil
|
||||
|
||||
+112
-51
@@ -1,13 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -16,7 +13,11 @@ import (
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// ---- a scripted IMAP server, same shape internal/email's tests use ---------
|
||||
// ---- a fake mailbox -------------------------------------------------------
|
||||
//
|
||||
// It fakes the READ, not the protocol: internal/email owns the IMAP tests, and
|
||||
// its dialer is unexported precisely so this package cannot substitute a
|
||||
// transport.
|
||||
|
||||
type fakeIMAP struct {
|
||||
msgs map[uint32]string
|
||||
@@ -24,52 +25,45 @@ type fakeIMAP struct {
|
||||
cmds []string
|
||||
}
|
||||
|
||||
func (f *fakeIMAP) serve(c net.Conn) {
|
||||
defer c.Close()
|
||||
fmt.Fprint(c, "* OK fake ready\r\n")
|
||||
r := bufio.NewReader(c)
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimRight(line, "\r\n"), " ", 2)
|
||||
if len(parts) != 2 {
|
||||
return
|
||||
}
|
||||
tag, cmd := parts[0], parts[1]
|
||||
f.cmds = append(f.cmds, cmd)
|
||||
upper := strings.ToUpper(cmd)
|
||||
switch {
|
||||
case strings.HasPrefix(upper, "LOGIN"), strings.HasPrefix(upper, "EXAMINE"):
|
||||
fmt.Fprintf(c, "%s OK\r\n", tag)
|
||||
case strings.HasPrefix(upper, "UID SEARCH"):
|
||||
var ids []string
|
||||
for _, u := range f.uids {
|
||||
ids = append(ids, strconv.FormatUint(uint64(u), 10))
|
||||
// fetch is the read seam the reader exposes: the daemon cannot reach
|
||||
// internal/email's dialer (it is unexported so nothing outside that package can
|
||||
// point the reader at a cleartext transport), so a test fakes the whole read.
|
||||
// The IMAP protocol itself is covered by internal/email's own tests.
|
||||
func (f *fakeIMAP) fetch(r *reader) func(string) ([]email.Message, error) {
|
||||
return func(string) ([]email.Message, error) {
|
||||
var out []email.Message
|
||||
var low uint32
|
||||
for _, uid := range f.uids {
|
||||
if low == 0 || uid < low {
|
||||
low = uid
|
||||
}
|
||||
fmt.Fprintf(c, "* SEARCH %s\r\n%s OK\r\n", strings.Join(ids, " "), tag)
|
||||
case strings.HasPrefix(upper, "UID FETCH"):
|
||||
uid, _ := strconv.ParseUint(strings.Fields(cmd)[2], 10, 32)
|
||||
if raw, ok := f.msgs[uint32(uid)]; ok {
|
||||
fmt.Fprintf(c, "* 1 FETCH (UID %d BODY[] {%d}\r\n%s)\r\n", uid, len(raw), raw)
|
||||
}
|
||||
fmt.Fprintf(c, "%s OK\r\n", tag)
|
||||
case strings.HasPrefix(upper, "LOGOUT"):
|
||||
fmt.Fprintf(c, "* BYE\r\n%s OK\r\n", tag)
|
||||
return
|
||||
default:
|
||||
fmt.Fprintf(c, "%s BAD\r\n", tag)
|
||||
}
|
||||
if low > 0 {
|
||||
r.state.retire(low)
|
||||
}
|
||||
for i := len(f.uids) - 1; i >= 0; i-- {
|
||||
uid := f.uids[i]
|
||||
if r.state.seen(uid) {
|
||||
continue
|
||||
}
|
||||
raw, ok := f.msgs[uid]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
f.cmds = append(f.cmds, fmt.Sprintf("UID FETCH %d", uid))
|
||||
msg, err := email.ParseMessage(uid, []byte(raw))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, msg)
|
||||
if r.max > 0 && len(out) >= r.max {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeIMAP) dial(_ string, timeout time.Duration) (*email.Conn, error) {
|
||||
cli, srv := net.Pipe()
|
||||
go f.serve(srv)
|
||||
return email.NewConn(cli, timeout)
|
||||
}
|
||||
|
||||
// ---- a fake core -----------------------------------------------------------
|
||||
|
||||
type fakeCore struct {
|
||||
@@ -96,12 +90,13 @@ func mail(subject, body string, extraHeaders ...string) string {
|
||||
|
||||
func newTestReader(t *testing.T, f *fakeIMAP, core *fakeCore, statePath string) *reader {
|
||||
t.Helper()
|
||||
return &reader{
|
||||
r := &reader{
|
||||
core: core, addr: "mail.example:993", user: "kami", mailbox: "INBOX",
|
||||
lookback: 72 * time.Hour, max: 25, timeout: 5 * time.Second,
|
||||
state: newSeenState(statePath),
|
||||
dial: f.dial,
|
||||
}
|
||||
r.fetchMail = f.fetch(r)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestPollHandsMessagesToCore(t *testing.T) {
|
||||
@@ -116,11 +111,26 @@ func TestPollHandsMessagesToCore(t *testing.T) {
|
||||
r := newTestReader(t, f, core, "")
|
||||
r.pollOnce(context.Background(), "secret")
|
||||
|
||||
// The newsletter is filtered before core is asked: only the real mail crosses.
|
||||
if len(core.got) != 1 {
|
||||
t.Fatalf("core saw %d messages, want 1 (the bulk one must not cross): %+v", len(core.got), core.got)
|
||||
// Two calls: the real mail with its text, and the newsletter as a verdict
|
||||
// with no content at all. Core is told about bulk rather than asked, so it
|
||||
// can count it without spending the model.
|
||||
if len(core.got) != 2 {
|
||||
t.Fatalf("core saw %d messages, want 2: %+v", len(core.got), core.got)
|
||||
}
|
||||
var got, bulk ipc.IngestMailReq
|
||||
for _, r := range core.got {
|
||||
if r.Junk {
|
||||
bulk = r
|
||||
} else {
|
||||
got = r
|
||||
}
|
||||
}
|
||||
if bulk.UID != 2 || !bulk.Junk {
|
||||
t.Errorf("bulk req = %+v, want uid 2 flagged junk", bulk)
|
||||
}
|
||||
if bulk.Subject != "" || bulk.Body != "" || bulk.From != "" {
|
||||
t.Errorf("a bulk verdict must carry no mail content: %+v", bulk)
|
||||
}
|
||||
got := core.got[0]
|
||||
if got.UID != 1 || got.Mailbox != "INBOX" || got.Subject != "Счёт" {
|
||||
t.Errorf("ingest req = %+v", got)
|
||||
}
|
||||
@@ -252,3 +262,54 @@ func TestRunRejectsEmptyPasswordFile(t *testing.T) {
|
||||
t.Errorf("an empty password file must be refused before dialling; err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A UID that never ingests pinned the high-water mark forever, because the mark
|
||||
// only advances through a contiguous run. Once that UID falls out of the
|
||||
// lookback window it can never be fetched again, so there is nothing left to
|
||||
// wait for and everything above it can leave the explicit set.
|
||||
func TestSeenStateRetiresAgedOutUIDs(t *testing.T) {
|
||||
s := newSeenState("")
|
||||
s.mark(1000) // 999 failed and was deliberately not marked
|
||||
s.mark(1001)
|
||||
if s.high != 0 || len(s.set) != 2 {
|
||||
t.Fatalf("high = %d, set = %v; want the mark pinned below the gap", s.high, s.set)
|
||||
}
|
||||
// The next SEARCH window starts at 1000: 999 has aged out.
|
||||
s.retire(1000)
|
||||
if s.high != 1001 {
|
||||
t.Errorf("high = %d, want 1001 once the gap is unreachable", s.high)
|
||||
}
|
||||
if len(s.set) != 0 {
|
||||
t.Errorf("explicit set = %v, want empty", s.set)
|
||||
}
|
||||
if !s.seen(999) || !s.seen(1001) || s.seen(1002) {
|
||||
t.Errorf("seen(999)=%v seen(1001)=%v seen(1002)=%v", s.seen(999), s.seen(1001), s.seen(1002))
|
||||
}
|
||||
}
|
||||
|
||||
// retire never moves the mark backwards: a UIDVALIDITY reset restarts UIDs low,
|
||||
// and that must not un-see a mailbox or re-see one.
|
||||
func TestSeenStateRetireNeverGoesBackwards(t *testing.T) {
|
||||
s := newSeenState("")
|
||||
s.mark(1)
|
||||
s.mark(2)
|
||||
s.retire(1)
|
||||
if s.high != 2 {
|
||||
t.Errorf("high = %d, want 2 unchanged", s.high)
|
||||
}
|
||||
}
|
||||
|
||||
// A poll must not leave the state file growing with UIDs that are already
|
||||
// covered by the high-water mark.
|
||||
func TestPollRetiresThroughTheSearchWindow(t *testing.T) {
|
||||
f := &fakeIMAP{uids: []uint32{100, 101}, msgs: map[uint32]string{100: mail("a", "b"), 101: mail("c", "d")}}
|
||||
core := &fakeCore{}
|
||||
r := newTestReader(t, f, core, "")
|
||||
r.pollOnce(context.Background(), "secret")
|
||||
if r.state.high != 101 {
|
||||
t.Errorf("high = %d, want 101 — everything below the search window is unreachable", r.state.high)
|
||||
}
|
||||
if len(r.state.set) != 0 {
|
||||
t.Errorf("explicit set = %v, want empty", r.state.set)
|
||||
}
|
||||
}
|
||||
|
||||
+34
-16
@@ -28,6 +28,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -193,6 +194,14 @@ func (p *poller) pollOnce(ctx context.Context) {
|
||||
//
|
||||
// Both windows are read from one diff call each. Two calls an hour against an
|
||||
// API whose whole job is this is not worth caching.
|
||||
//
|
||||
// The write is UNCONDITIONAL, unlike every other poll in this file. The
|
||||
// value-dedupe in writeIfChangedRaw only advances ts when the number moves, and
|
||||
// for money that made ts mean "last changed" while the reader was asking it "as
|
||||
// of when". A quiet 27 hours had core prefixing "данные от 30.07" to a figure
|
||||
// that was current. The value now carries its own read stamp, so it differs
|
||||
// every poll anyway and there is nothing left for the dedupe to catch.
|
||||
|
||||
// moneyWindow — one fact key and the period it covers.
|
||||
type moneyWindow struct {
|
||||
key string
|
||||
@@ -215,12 +224,14 @@ func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
|
||||
}
|
||||
continue
|
||||
}
|
||||
val, ok := sum.Value()
|
||||
val, ok := sum.Value(now)
|
||||
if !ok {
|
||||
// Nothing read. Silence, not a zero.
|
||||
// Nothing read. Silence, not a zero. The last good fact stays, and
|
||||
// the window stamp inside it is what stops core reciting yesterday's
|
||||
// day total as today's after midnight.
|
||||
continue
|
||||
}
|
||||
if err := p.writeIfChangedRaw(ctx, w.key, zenmoney.Source, val, now); err != nil && firstErr == nil {
|
||||
if err := p.writeMoneyFact(ctx, w.key, val, now); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
@@ -422,20 +433,27 @@ func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal str
|
||||
return nil
|
||||
}
|
||||
|
||||
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
|
||||
// errors.Is is the right check; keep a helper so the switch above reads clean.
|
||||
func isNoFact(err error) bool {
|
||||
for e := err; e != nil; {
|
||||
if e == ipc.ErrNoFact {
|
||||
return true
|
||||
}
|
||||
u, ok := e.(interface{ Unwrap() error })
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
e = u.Unwrap()
|
||||
// writeMoneyFact writes a money fact every poll, with no value comparison. See
|
||||
// the comment above pollZenmoney for why this one does not go through
|
||||
// writeIfChangedRaw.
|
||||
//
|
||||
// The log line names the key only, never the figures: mavpoll's log is not the
|
||||
// place his spending ends up.
|
||||
func (p *poller) writeMoneyFact(ctx context.Context, key, jsonVal string, now time.Time) error {
|
||||
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: now, Kind: "env", Key: key, Value: jsonVal,
|
||||
Source: zenmoney.Source, Confidence: 1.0,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write %s: %w", key, err)
|
||||
}
|
||||
return false
|
||||
log.Printf("mavpoll: %s read (%s)", key, zenmoney.Source)
|
||||
return nil
|
||||
}
|
||||
|
||||
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
|
||||
// errors.Is is the right check.
|
||||
func isNoFact(err error) bool {
|
||||
return errors.Is(err, ipc.ErrNoFact)
|
||||
}
|
||||
|
||||
func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) {
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
package main
|
||||
|
||||
// Golden-audio STT tests (Vikunja #288).
|
||||
//
|
||||
// These push real audio through the real whisper.cpp binding. What that
|
||||
// covers, precisely, is two things: the model still transcribes known speech
|
||||
// well enough for the router to act on it, and the silence gate still lets real
|
||||
// speech through. A regression in either shows up in `make test` rather than in
|
||||
// the owner talking to a daemon that mishears him.
|
||||
//
|
||||
// It is worth being exact about what is NOT covered, because this comment used
|
||||
// to claim more. Nothing here resamples: audio.PCMFromWAV refuses anything that
|
||||
// is not 16 kHz mono s16, the fixtures arrive at 16 kHz from ffmpeg, and there
|
||||
// is no conversion step between the WAV and whisper_full. Nothing here
|
||||
// exercises language selection either: the hint comes out of the manifest
|
||||
// already correct and goes straight into the request, so how mavsttd chooses a
|
||||
// language is untested. And a wrong model path is not caught when it is the
|
||||
// default one, because a box without the model skips; an explicitly set
|
||||
// MAVEN_WHISPER_MODEL that does not exist is a failure, since that is a
|
||||
// mistake and not an absence.
|
||||
//
|
||||
// The fixtures are piper-synthesised, not recorded — see
|
||||
// scripts/gen-stt-fixtures.sh. Nothing of the owner's voice is committed, and
|
||||
// any fixture can be rebuilt from the script plus a voice model.
|
||||
//
|
||||
// Matching is deliberately tolerant. Golden transcripts are model-dependent:
|
||||
// swapping ggml-small for a different whisper build moves punctuation, casing
|
||||
// and the odd word ending, and an exact-string assertion would turn every
|
||||
// model swap into a fixture rewrite. Each case therefore asserts two things —
|
||||
// the words that carry the intent are present, and the word error rate
|
||||
// against the reference stays under a per-case ceiling.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/worker"
|
||||
)
|
||||
|
||||
// goldenModelPath — the whisper model the golden tests run against. Same file
|
||||
// the Makefile's run-stt target uses. Overridable so a box that keeps its
|
||||
// models elsewhere can still run these.
|
||||
func goldenModelPath() string {
|
||||
if p := os.Getenv("MAVEN_WHISPER_MODEL"); p != "" {
|
||||
return p
|
||||
}
|
||||
return filepath.Join("..", "..", "models", "stt", "ggml-small.bin")
|
||||
}
|
||||
|
||||
type goldenCase struct {
|
||||
Name string `json:"name"`
|
||||
WAV string `json:"wav"`
|
||||
Lang string `json:"lang"`
|
||||
Text string `json:"text"`
|
||||
Keywords []string `json:"keywords"`
|
||||
// MeasuredWER is what this case scored when the ceiling was last set, so
|
||||
// a model swap is a diff to a recorded number rather than silence.
|
||||
MeasuredWER float64 `json:"measured_wer"`
|
||||
MaxWER float64 `json:"max_wer"`
|
||||
}
|
||||
|
||||
type goldenManifest struct {
|
||||
Cases []goldenCase `json:"cases"`
|
||||
}
|
||||
|
||||
func loadGoldenManifest(t *testing.T) goldenManifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "golden_v1.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read golden manifest: %v", err)
|
||||
}
|
||||
var m goldenManifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("parse golden manifest: %v", err)
|
||||
}
|
||||
if len(m.Cases) == 0 {
|
||||
t.Fatal("golden manifest has no cases")
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// normalizeTranscript lowercases, drops punctuation, folds the Russian ё onto
|
||||
// е (whisper is inconsistent about it and the router does not care), and
|
||||
// collapses whitespace. Everything the comparison does happens on this form.
|
||||
func normalizeTranscript(s string) []string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(s) {
|
||||
switch {
|
||||
case r == 'ё':
|
||||
b.WriteRune('е')
|
||||
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
return strings.Fields(b.String())
|
||||
}
|
||||
|
||||
// wordErrorRate is the Levenshtein distance between two word sequences,
|
||||
// divided by the length of the reference. 0 means identical; it can exceed 1
|
||||
// when the hypothesis is much longer than the reference.
|
||||
func wordErrorRate(ref, hyp []string) float64 {
|
||||
if len(ref) == 0 {
|
||||
if len(hyp) == 0 {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
prev := make([]int, len(hyp)+1)
|
||||
cur := make([]int, len(hyp)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ref); i++ {
|
||||
cur[0] = i
|
||||
for j := 1; j <= len(hyp); j++ {
|
||||
cost := 1
|
||||
if ref[i-1] == hyp[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
cur[j] = min(prev[j]+1, min(cur[j-1]+1, prev[j-1]+cost))
|
||||
}
|
||||
prev, cur = cur, prev
|
||||
}
|
||||
return float64(prev[len(hyp)]) / float64(len(ref))
|
||||
}
|
||||
|
||||
// missingKeywords returns the keywords absent from the hypothesis. A keyword
|
||||
// matches on prefix, so a different case ending ("воды" vs "воду") does not
|
||||
// fail the assertion — the router's stage-0 grammar is stem-shaped too.
|
||||
func missingKeywords(keywords []string, hyp []string) []string {
|
||||
var missing []string
|
||||
for _, kw := range keywords {
|
||||
want := normalizeTranscript(kw)
|
||||
if len(want) == 0 {
|
||||
continue
|
||||
}
|
||||
if !containsSeq(hyp, want) {
|
||||
missing = append(missing, kw)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func containsSeq(hyp, want []string) bool {
|
||||
for i := 0; i+len(want) <= len(hyp); i++ {
|
||||
ok := true
|
||||
for j, w := range want {
|
||||
// Prefix match, so inflection differences pass but
|
||||
// distinct words do not.
|
||||
if !looseWordMatch(hyp[i+j], w) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// looseWordMatch reports whether got is want, or an inflection of it.
|
||||
//
|
||||
// A shared prefix alone is not enough. "воды" retains three runes, so "водка"
|
||||
// used to satisfy the ru_fact keyword and the test passed on whisper hearing
|
||||
// "выпил водки". "disk" retains "dis", which "display", "distance" and
|
||||
// "discuss" all match. So the hypothesis is also capped in length: a case
|
||||
// ending adds a rune or two, it does not add a syllable. Short words get no
|
||||
// slack at all, because there is nothing left of them after a prefix cut.
|
||||
func looseWordMatch(got, want string) bool {
|
||||
if got == want {
|
||||
return true
|
||||
}
|
||||
g, w := []rune(got), []rune(want)
|
||||
n := len(w) - 1
|
||||
if len(w) > 6 {
|
||||
n = len(w) - 2
|
||||
}
|
||||
// Words of three runes or fewer have no room for a safe prefix: require
|
||||
// an exact match rather than letting "час" pass for "часть".
|
||||
if n < 3 || len(g) < n {
|
||||
return false
|
||||
}
|
||||
extra := 2
|
||||
if len(w) <= 4 {
|
||||
extra = 0
|
||||
}
|
||||
if len(g) > len(w)+extra {
|
||||
return false
|
||||
}
|
||||
return string(g[:n]) == string(w[:n])
|
||||
}
|
||||
|
||||
// --- the model-backed test -------------------------------------------------
|
||||
|
||||
func TestGoldenAudioTranscription(t *testing.T) {
|
||||
m := loadGoldenManifest(t)
|
||||
|
||||
model := goldenModelPath()
|
||||
if _, err := os.Stat(model); err != nil {
|
||||
// An explicit override that points at nothing is a mistake, not a box
|
||||
// without the model. Skipping there made a typo look like a pass.
|
||||
if os.Getenv("MAVEN_WHISPER_MODEL") != "" {
|
||||
t.Fatalf("MAVEN_WHISPER_MODEL=%s does not exist: %v", model, err)
|
||||
}
|
||||
t.Skipf("whisper model %s absent (%v) — set MAVEN_WHISPER_MODEL or see AGENTS.md", model, err)
|
||||
}
|
||||
|
||||
// Same gate thresholds as mavsttd's defaults, so a regression in the
|
||||
// silence gate shows up here as an empty transcript.
|
||||
h, err := newWhisperHandler(model, 300, 0.01)
|
||||
if err != nil {
|
||||
t.Fatalf("load whisper model %s: %v", model, err)
|
||||
}
|
||||
defer h.Close()
|
||||
|
||||
for _, c := range m.Cases {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
path := filepath.Join("testdata", c.WAV)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// Not a skip. A fixture the generator failed to write is a
|
||||
// broken checkout, and skipping made `make test` green on one.
|
||||
t.Fatalf("fixture %s absent (%v) — run scripts/gen-stt-fixtures.sh", path, err)
|
||||
}
|
||||
format, pcm, err := audio.PCMFromWAV(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("%s is not canonical 16k mono PCM: %v", path, err)
|
||||
}
|
||||
|
||||
resp, err := h.Transcribe(context.Background(), worker.TranscribeReq{
|
||||
Audio: audio.Audio{Format: format, Bytes: pcm},
|
||||
Lang: c.Lang,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("transcribe %s: %v", c.WAV, err)
|
||||
}
|
||||
t.Logf("%s → %q (confidence %.3f)", c.WAV, resp.Text, resp.Confidence)
|
||||
|
||||
if strings.TrimSpace(resp.Text) == "" {
|
||||
t.Fatalf("%s transcribed to empty text — the silence gate ate real speech", c.WAV)
|
||||
}
|
||||
if resp.Confidence <= 0 {
|
||||
t.Errorf("%s: confidence %v, want > 0", c.WAV, resp.Confidence)
|
||||
}
|
||||
|
||||
hyp := normalizeTranscript(resp.Text)
|
||||
ref := normalizeTranscript(c.Text)
|
||||
|
||||
if missing := missingKeywords(c.Keywords, hyp); len(missing) > 0 {
|
||||
t.Errorf("%s: missing keywords %v in %q", c.WAV, missing, resp.Text)
|
||||
}
|
||||
wer := wordErrorRate(ref, hyp)
|
||||
if wer > c.MaxWER {
|
||||
t.Errorf("%s: WER %.2f > %.2f (measured %.2f when the ceiling was set)\n want: %q\n got: %q",
|
||||
c.WAV, wer, c.MaxWER, c.MeasuredWER, c.Text, resp.Text)
|
||||
}
|
||||
t.Logf("%s: WER %.2f (ceiling %.2f, was %.2f)", c.WAV, wer, c.MaxWER, c.MeasuredWER)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenFixturesAreCanonical checks the committed audio without needing a
|
||||
// model, so a fixture regenerated at the wrong sample rate fails on every box.
|
||||
func TestGoldenFixturesAreCanonical(t *testing.T) {
|
||||
m := loadGoldenManifest(t)
|
||||
for _, c := range m.Cases {
|
||||
path := filepath.Join("testdata", c.WAV)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Errorf("fixture %s missing: %v", path, err)
|
||||
continue
|
||||
}
|
||||
format, pcm, err := audio.PCMFromWAV(raw)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
if !format.IsValid() {
|
||||
t.Errorf("%s: format %+v is not canonical", path, format)
|
||||
}
|
||||
a := audio.Audio{Format: format, Bytes: pcm}
|
||||
if d := a.Duration(); d < 0.5 || d > 10 {
|
||||
t.Errorf("%s: duration %.2fs outside the sane 0.5–10s fixture range", path, d)
|
||||
}
|
||||
// The fixture must clear mavsttd's own silence gate, otherwise the
|
||||
// model test below would be asserting on a gated empty string.
|
||||
if reason := gateReason(pcmSamples(pcm), whisperSampleRate, 300, 0.01); reason != "" {
|
||||
t.Errorf("%s: would be gated as %s", path, reason)
|
||||
}
|
||||
if len(c.Keywords) == 0 {
|
||||
t.Errorf("%s: manifest case has no keywords", c.Name)
|
||||
}
|
||||
// An empty reference makes wordErrorRate return 1 for every
|
||||
// hypothesis, so the WER assertion fires with nothing useful to say.
|
||||
if len(normalizeTranscript(c.Text)) == 0 {
|
||||
t.Errorf("%s: manifest case has no reference text", c.Name)
|
||||
}
|
||||
if c.Lang != "ru" && c.Lang != "en" {
|
||||
t.Errorf("%s: lang %q is not one of the two languages mavsttd is run with", c.Name, c.Lang)
|
||||
}
|
||||
if c.MaxWER <= 0 || c.MaxWER > 1 {
|
||||
t.Errorf("%s: max_wer %v outside (0,1]", c.Name, c.MaxWER)
|
||||
}
|
||||
if c.MeasuredWER > c.MaxWER {
|
||||
t.Errorf("%s: measured_wer %v is above max_wer %v, so the ceiling was never met", c.Name, c.MeasuredWER, c.MaxWER)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- matcher unit tests (no model, no fixtures) ----------------------------
|
||||
|
||||
func TestNormalizeTranscript(t *testing.T) {
|
||||
got := normalizeTranscript(" Ещё, Раз... ")
|
||||
want := []string{"еще", "раз"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("normalizeTranscript = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordErrorRate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ref, hyp string
|
||||
want float64
|
||||
}{
|
||||
{"identical", "напомни мне через час", "Напомни мне через час.", 0},
|
||||
{"one substitution", "напомни мне через час", "напомни мне через день", 0.25},
|
||||
{"one deletion", "напомни мне через час", "напомни мне час", 0.25},
|
||||
{"empty hypothesis", "напомни мне", "", 1},
|
||||
{"both empty", "", "", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := wordErrorRate(normalizeTranscript(c.ref), normalizeTranscript(c.hyp))
|
||||
if got != c.want {
|
||||
t.Fatalf("WER = %v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingKeywords(t *testing.T) {
|
||||
hyp := normalizeTranscript("Отметь, что я выпил воду.")
|
||||
if got := missingKeywords([]string{"воды", "отметь"}, hyp); len(got) != 0 {
|
||||
t.Fatalf("missingKeywords = %v, want none (inflection must not fail the match)", got)
|
||||
}
|
||||
if got := missingKeywords([]string{"календарю"}, hyp); len(got) != 1 {
|
||||
t.Fatalf("missingKeywords = %v, want the absent keyword reported", got)
|
||||
}
|
||||
// A short word must match exactly — no 4-rune prefix shortcut that would
|
||||
// let "час" pass for "часть".
|
||||
hyp2 := normalizeTranscript("через час")
|
||||
if got := missingKeywords([]string{"часть"}, hyp2); len(got) != 1 {
|
||||
t.Fatalf("missingKeywords = %v, want %q reported missing", got, "часть")
|
||||
}
|
||||
// A prefix is not a word. These are different words that share one, and
|
||||
// each of them used to satisfy the keyword it is paired with.
|
||||
different := [][2]string{
|
||||
{"воды", "Я выпил водки."},
|
||||
{"disk", "check the display"},
|
||||
{"disk", "we should discuss it"},
|
||||
{"server", "a serverless function"},
|
||||
}
|
||||
for _, d := range different {
|
||||
if got := missingKeywords([]string{d[0]}, normalizeTranscript(d[1])); len(got) != 1 {
|
||||
t.Errorf("keyword %q was satisfied by %q", d[0], d[1])
|
||||
}
|
||||
}
|
||||
// And the inflections still pass, which is the whole point of the loose
|
||||
// match.
|
||||
same := [][2]string{
|
||||
{"воды", "выпил воду"},
|
||||
{"напомни", "напомните мне"},
|
||||
{"календарю", "по календаре"},
|
||||
{"restart", "restarted the server"},
|
||||
}
|
||||
for _, d := range same {
|
||||
if got := missingKeywords([]string{d[0]}, normalizeTranscript(d[1])); len(got) != 0 {
|
||||
t.Errorf("keyword %q was not matched by %q", d[0], d[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"note": "Golden STT fixtures. Audio is piper-synthesised, not recorded — see scripts/gen-stt-fixtures.sh, which reads `text` from this file and synthesises from it. This is the only source of the spoken words; regenerate with that script and do not hand-edit `wav`.",
|
||||
"wer_note": "max_wer is set just above what each case actually measures against ggml-small, recorded in `measured_wer` on 2026-08-01. A flat 0.34 over a five-word reference tolerated two wrong words and left most of the range unguarded. A model swap should show up as a diff to these numbers, not as silence: rerun `make test-stt-golden`, read the logged transcript, and move both fields together.",
|
||||
"cases": [
|
||||
{
|
||||
"name": "ru_reminder",
|
||||
"wav": "ru_reminder.wav",
|
||||
"lang": "ru",
|
||||
"text": "напомни мне через час позвонить маме",
|
||||
"keywords": ["напомни", "час", "позвонить"],
|
||||
"measured_wer": 0.0,
|
||||
"max_wer": 0.1
|
||||
},
|
||||
{
|
||||
"name": "ru_fact",
|
||||
"wav": "ru_fact.wav",
|
||||
"lang": "ru",
|
||||
"text": "отметь что я выпил воды",
|
||||
"keywords": ["отметь", "воды"],
|
||||
"measured_wer": 0.2,
|
||||
"max_wer": 0.25
|
||||
},
|
||||
{
|
||||
"name": "ru_query",
|
||||
"wav": "ru_query.wav",
|
||||
"lang": "ru",
|
||||
"text": "что у меня сегодня по календарю",
|
||||
"keywords": ["сегодня", "календарю"],
|
||||
"measured_wer": 0.0,
|
||||
"max_wer": 0.1
|
||||
},
|
||||
{
|
||||
"name": "en_act",
|
||||
"wav": "en_act.wav",
|
||||
"lang": "en",
|
||||
"text": "restart the web server and check the disk space",
|
||||
"keywords": ["restart", "server", "disk"],
|
||||
"measured_wer": 0.0,
|
||||
"max_wer": 0.1
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -66,6 +66,19 @@ func gateReason(samples []float32, rate, minMs int, minRMS float64) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// pcmSamples converts canonical s16le little-endian PCM to the float32 range
|
||||
// whisper wants. Shared with the golden tests: they used to carry their own
|
||||
// copy, so a regression here (a /32767 divisor, a byte order slip) left the
|
||||
// assertion that the fixtures clear the silence gate green.
|
||||
func pcmSamples(b []byte) []float32 {
|
||||
out := make([]float32, len(b)/2)
|
||||
for i := range out {
|
||||
s := int16(b[i*2]) | int16(b[i*2+1])<<8
|
||||
out[i] = float32(s) / 32768.0
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeReq) (worker.TranscribeResp, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return worker.TranscribeResp{}, fmt.Errorf("whisper: context done before transcribe: %w", err)
|
||||
@@ -75,12 +88,7 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
|
||||
return worker.TranscribeResp{}, fmt.Errorf("whisper: empty audio")
|
||||
}
|
||||
|
||||
nSamples := len(a.Bytes) / 2
|
||||
samples := make([]float32, nSamples)
|
||||
for i := 0; i < nSamples; i++ {
|
||||
s := int16(a.Bytes[i*2]) | int16(a.Bytes[i*2+1])<<8
|
||||
samples[i] = float32(s) / 32768.0
|
||||
}
|
||||
samples := pcmSamples(a.Bytes)
|
||||
|
||||
// Silence gate: drop non-speech before whisper hallucinates on it.
|
||||
if reason := gateReason(samples, whisperSampleRate, h.minMs, h.minRMS); reason != "" {
|
||||
@@ -111,7 +119,7 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
|
||||
ch := make(chan result, 1)
|
||||
cSamples := (*C.float)(unsafe.Pointer(&samples[0]))
|
||||
go func() {
|
||||
ch <- result{code: int(C.whisper_full(h.ctx, params, cSamples, C.int(nSamples)))}
|
||||
ch <- result{code: int(C.whisper_full(h.ctx, params, cSamples, C.int(len(samples))))}
|
||||
}()
|
||||
select {
|
||||
case r := <-ch:
|
||||
|
||||
+12
-3
@@ -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)
|
||||
}
|
||||
|
||||
+44
-92
@@ -12,8 +12,18 @@
|
||||
// (30ms frames, 16kHz PCM) matches silero-vad's input interface exactly, so
|
||||
// swapping energy-threshold for ONNX-inference is a local change in vad.go.
|
||||
//
|
||||
// While a reply is playing the capture side is muted (half-duplex): without
|
||||
// it, Maven's own voice comes back in through the mic and she answers
|
||||
// herself. -barge-in punches one hole in that gate — sustained energy above
|
||||
// -barge-in-rms cuts playback so he can talk over her. It is off by default
|
||||
// because the threshold is room-specific; see playback.go. The threshold is a
|
||||
// raw frame RMS and has no reference to what the speaker actually leaks, so
|
||||
// the daemon logs the mean energy of the frames it suppressed while speaking.
|
||||
// Set -barge-in-rms from those numbers rather than by guessing.
|
||||
//
|
||||
// usage:
|
||||
// mavwaked # default ALSA device, 127.0.0.1:9100
|
||||
// mavwaked -barge-in # let him interrupt her mid-reply
|
||||
// mavwaked -device hw:1,0 -addr 10.42.0.1:9100
|
||||
// mavwaked -test file.wav # read from file, no arecord
|
||||
package main
|
||||
@@ -60,6 +70,9 @@ func run(args []string) error {
|
||||
silenceMs := flag.Int("silence-ms", defaultSilenceMs, "silence ms to end utterance")
|
||||
maxMs := flag.Int("max-ms", defaultMaxMs, "max utterance ms")
|
||||
testFile := flag.String("test", "", "read PCM from file instead of arecord (testing only)")
|
||||
bargeIn := flag.Bool("barge-in", false, "cut Maven off when he talks over her (needs a room-tuned -barge-in-rms)")
|
||||
bargeRMS := flag.Int("barge-in-rms", defaultBargeRMS, "RMS x10000 a frame must clear to count as barge-in")
|
||||
bargeFrames := flag.Int("barge-in-frames", defaultBargeFrames, "consecutive frames over -barge-in-rms before playback is cut")
|
||||
flag.CommandLine.Parse(args)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||
@@ -117,14 +130,29 @@ func run(args []string) error {
|
||||
|
||||
defer src.Close()
|
||||
|
||||
return captureLoop(ctx, src, vad, vc, *lang)
|
||||
var barge bargeInConfig
|
||||
if *bargeIn {
|
||||
barge = bargeInConfig{RMS: float64(*bargeRMS) / 10000.0, Frames: *bargeFrames}
|
||||
if barge.Enabled() {
|
||||
log.Printf("mavwaked: barge-in on (rms %.4f x %d frames)", barge.RMS, barge.Frames)
|
||||
} else {
|
||||
// The log used to say "barge-in on (rms 0.0000 x 5)" here and then
|
||||
// nothing happened, because Enabled needs a positive threshold.
|
||||
log.Printf("mavwaked: -barge-in was passed but rms %.4f x %d frames disables it; "+
|
||||
"both must be above zero, so barge-in is OFF",
|
||||
barge.RMS, barge.Frames)
|
||||
}
|
||||
}
|
||||
sess := newSession(vad, newAplayPlayer(), &voiceSender{vc: vc}, *lang, barge)
|
||||
|
||||
return captureLoop(ctx, src, sess)
|
||||
}
|
||||
|
||||
// captureLoop reads PCM from src, runs VAD, and sends complete utterances to
|
||||
// the voice server. Returns when ctx is done or src is exhausted.
|
||||
func captureLoop(ctx context.Context, src io.Reader, vad *VAD, vc *voice.Client, lang string) error {
|
||||
// captureLoop reads PCM from src and hands whole frames to the session.
|
||||
// Returns when ctx is done or src is exhausted.
|
||||
func captureLoop(ctx context.Context, src io.Reader, sess *session) error {
|
||||
br := bufio.NewReaderSize(src, defaultReadSize)
|
||||
frameBytes := vad.FrameSamples() * 2 // 480 samples × 2 bytes = 960 bytes per 30ms
|
||||
frameBytes := sess.vad.FrameSamples() * 2 // 480 samples × 2 bytes = 960 bytes per 30ms
|
||||
|
||||
log.Printf("mavwaked: capture loop starting (frame=%d bytes, %dms)",
|
||||
frameBytes, defaultFrameMs)
|
||||
@@ -147,7 +175,7 @@ func captureLoop(ctx context.Context, src io.Reader, vad *VAD, vc *voice.Client,
|
||||
// Flush partial frame.
|
||||
partial = append(partial, buf[:n]...)
|
||||
if len(partial) >= frameBytes {
|
||||
if err := processFrame(partial[:frameBytes], vad, vc, lang); err != nil {
|
||||
if err := sess.feed(ctx, partial[:frameBytes]); err != nil {
|
||||
log.Printf("mavwaked: process frame: %v", err)
|
||||
}
|
||||
partial = partial[frameBytes:]
|
||||
@@ -165,107 +193,31 @@ func captureLoop(ctx context.Context, src io.Reader, vad *VAD, vc *voice.Client,
|
||||
partial = nil
|
||||
}
|
||||
|
||||
if err := processFrame(full, vad, vc, lang); err != nil {
|
||||
if err := sess.feed(ctx, full); err != nil {
|
||||
log.Printf("mavwaked: process frame: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// processFrame feeds one 30ms PCM frame to the VAD and sends any completed
|
||||
// utterance to the voice server.
|
||||
func processFrame(frame []byte, vad *VAD, vc *voice.Client, lang string) error {
|
||||
samples := PCMToI16(frame)
|
||||
utt, state := vad.Feed(samples)
|
||||
// voiceSender is the production utteranceSender: one PushToTalk round-trip
|
||||
// over the voice wire. SurfaceVoice (not the default SurfacePCClient that
|
||||
// c.PushToTalk uses) caps everything at L0, which is what makes an accidental
|
||||
// VAD trigger safe.
|
||||
type voiceSender struct{ vc *voice.Client }
|
||||
|
||||
if state == StateSpeech {
|
||||
// Speech is in progress; nothing to send yet.
|
||||
return nil
|
||||
}
|
||||
|
||||
if utt.Bytes == nil {
|
||||
// Still in silence, or short speech that didn't trigger.
|
||||
return nil
|
||||
}
|
||||
|
||||
// We have a complete utterance — send it to the voice server.
|
||||
return sendUtterance(context.Background(), utt, vc, lang)
|
||||
}
|
||||
|
||||
// sendUtterance sends audio to the voice server and plays the reply.
|
||||
func sendUtterance(ctx context.Context, utt audio.Audio, vc *voice.Client, lang string) error {
|
||||
dur := utt.Duration()
|
||||
log.Printf("mavwaked: utterance complete (%.2fs, %d bytes), sending...",
|
||||
dur, len(utt.Bytes))
|
||||
|
||||
// Use SendRequest directly so we can set SurfaceVoice instead of the
|
||||
// default SurfacePCClient that c.PushToTalk uses.
|
||||
func (s *voiceSender) Send(ctx context.Context, utt audio.Audio, lang string) (audio.Audio, error) {
|
||||
var resp voice.PushToTalkResp
|
||||
err := vc.SendRequest(ctx, voice.MethodPushToTalk, voice.PushToTalkReq{
|
||||
err := s.vc.SendRequest(ctx, voice.MethodPushToTalk, voice.PushToTalkReq{
|
||||
Audio: utt,
|
||||
Lang: lang,
|
||||
Surface: voice.SurfaceVoice,
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("push-to-talk: %w", err)
|
||||
return audio.Audio{}, fmt.Errorf("push-to-talk: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("mavwaked: reply: %q (%.2fs audio)", resp.ReplyText, resp.ReplyAudio.Duration())
|
||||
|
||||
// Play the reply audio.
|
||||
if len(resp.ReplyAudio.Bytes) > 0 {
|
||||
go playAudio(resp.ReplyAudio)
|
||||
} else {
|
||||
log.Printf("mavwaked: empty reply audio (text only)")
|
||||
}
|
||||
|
||||
if len(resp.RoutedChannels) > 0 {
|
||||
log.Printf("mavwaked: also routed to: %v", resp.RoutedChannels)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// playAudio pipes PCM audio to aplay(1) for playback. Runs in a goroutine.
|
||||
func playAudio(a audio.Audio) {
|
||||
// Build WAV header for aplay (or pipe raw PCM with the right format flags).
|
||||
cmd := exec.Command("aplay",
|
||||
"-f", "S16_LE",
|
||||
"-r", fmt.Sprintf("%d", a.Format.SampleRate),
|
||||
"-c", fmt.Sprintf("%d", a.Format.Channels),
|
||||
"-t", "raw",
|
||||
)
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
log.Printf("mavwaked: aplay stdin pipe: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Printf("mavwaked: start aplay: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Write audio to aplay's stdin.
|
||||
if _, err := stdin.Write(a.Bytes); err != nil {
|
||||
log.Printf("mavwaked: write to aplay: %v", err)
|
||||
}
|
||||
_ = stdin.Close()
|
||||
|
||||
// Wait for playback to finish (with a timeout).
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
log.Printf("mavwaked: aplay: %v", err)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
log.Printf("mavwaked: aplay timeout, killing")
|
||||
_ = cmd.Process.Kill()
|
||||
<-done
|
||||
}
|
||||
return resp.ReplyAudio, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
// Reply playback, and the half-duplex gate around it (Vikunja #287).
|
||||
//
|
||||
// Before this, playback was `go playAudio(reply)` — fire and forget, with no
|
||||
// handle on the running aplay. Two things fell out of that, and both are
|
||||
// audible:
|
||||
//
|
||||
// 1. Self-trigger. The capture loop keeps feeding the VAD while the speaker
|
||||
// is playing, so Maven's own reply comes back in through the mic, trips
|
||||
// the VAD, and is sent to the daemon as a fresh utterance. She answers
|
||||
// herself. There is no acoustic echo canceller in this pipeline, so the
|
||||
// only correct fix is half-duplex: while she is speaking, the capture
|
||||
// side is muted.
|
||||
//
|
||||
// 2. No barge-in. Talking over her did nothing — there was nothing to
|
||||
// cancel, because nobody held the process handle.
|
||||
//
|
||||
// The two are the same mechanism seen from opposite sides, so they live
|
||||
// together here. Echo suppression is unconditional (it fixes a bug). Barge-in
|
||||
// is off unless -barge-in is passed, because it needs a room-specific energy
|
||||
// threshold: with no echo canceller, the only way to tell "he is talking over
|
||||
// her" from "the mic is hearing her" is that he is louder, and how much
|
||||
// louder depends on where the mic sits relative to the speaker.
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
|
||||
// playbackMargin is the slack over the reply's own duration before a stuck
|
||||
// aplay is killed. Enough for ALSA to open the device and drain its buffer,
|
||||
// short enough that a busy device does not cost her a turn.
|
||||
const playbackMargin = 2 * time.Second
|
||||
|
||||
// player plays one reply at a time and can be cut off mid-utterance.
|
||||
type player interface {
|
||||
// Play starts playback of a, replacing anything already playing, and
|
||||
// returns immediately.
|
||||
Play(a audio.Audio)
|
||||
// Stop ends playback now. A no-op when nothing is playing.
|
||||
Stop()
|
||||
// Playing reports whether audio is currently going out of the speaker.
|
||||
Playing() bool
|
||||
}
|
||||
|
||||
// aplayPlayer pipes raw PCM to aplay(1). Stop kills the child, which is what
|
||||
// makes barge-in instant rather than "instant at the end of the sentence".
|
||||
type aplayPlayer struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
playing bool
|
||||
// gen rises on every Play/Stop so a finishing playback cannot clear the
|
||||
// playing flag of the one that replaced it.
|
||||
gen uint64
|
||||
}
|
||||
|
||||
func newAplayPlayer() *aplayPlayer { return &aplayPlayer{} }
|
||||
|
||||
func (p *aplayPlayer) Play(a audio.Audio) {
|
||||
if len(a.Bytes) == 0 {
|
||||
return
|
||||
}
|
||||
p.Stop()
|
||||
|
||||
cmd := exec.Command("aplay",
|
||||
"-f", "S16_LE",
|
||||
"-r", strconv.Itoa(a.Format.SampleRate),
|
||||
"-c", strconv.Itoa(a.Format.Channels),
|
||||
"-t", "raw",
|
||||
)
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
log.Printf("mavwaked: aplay stdin pipe: %v", err)
|
||||
return
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Printf("mavwaked: start aplay: %v", err)
|
||||
_ = stdin.Close()
|
||||
return
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.gen++
|
||||
gen := p.gen
|
||||
p.cmd = cmd
|
||||
p.playing = true
|
||||
p.mu.Unlock()
|
||||
|
||||
// Bound the mute window by the reply itself. Playing() gates all capture
|
||||
// now, so a wedged aplay does not merely go silent, it makes her deaf for
|
||||
// as long as the flag is set. The old ceiling was a flat 30s inherited
|
||||
// from the fire-and-forget version, where it only bounded a leaked
|
||||
// goroutine. A reply cannot legitimately take longer than it lasts.
|
||||
limit := time.Duration(a.Duration()*float64(time.Second)) + playbackMargin
|
||||
|
||||
go func() {
|
||||
if _, err := stdin.Write(a.Bytes); err != nil {
|
||||
// Broken pipe is the expected outcome of Stop().
|
||||
log.Printf("mavwaked: write to aplay: %v", err)
|
||||
}
|
||||
_ = stdin.Close()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
log.Printf("mavwaked: aplay: %v", err)
|
||||
}
|
||||
case <-time.After(limit):
|
||||
log.Printf("mavwaked: aplay did not finish %.1fs of audio within %s, killing (capture was muted the whole time)",
|
||||
a.Duration(), limit)
|
||||
if pr := cmd.Process; pr != nil {
|
||||
_ = pr.Kill()
|
||||
}
|
||||
<-done
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
if p.gen == gen {
|
||||
p.playing = false
|
||||
p.cmd = nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
func (p *aplayPlayer) Stop() {
|
||||
p.mu.Lock()
|
||||
cmd := p.cmd
|
||||
if cmd != nil {
|
||||
p.gen++
|
||||
p.playing = false
|
||||
p.cmd = nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *aplayPlayer) Playing() bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.playing
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
|
||||
// The real player must be safe to poke when nothing is playing — the capture
|
||||
// loop calls Playing() on every 30ms frame, and Stop() lands on an idle
|
||||
// player whenever a barge-in races the end of a reply. Neither may need
|
||||
// aplay(1) to be installed.
|
||||
func TestAplayPlayerIdleIsSafe(t *testing.T) {
|
||||
p := newAplayPlayer()
|
||||
if p.Playing() {
|
||||
t.Fatal("a fresh player reports playing")
|
||||
}
|
||||
p.Stop()
|
||||
p.Stop()
|
||||
if p.Playing() {
|
||||
t.Fatal("playing after Stop on an idle player")
|
||||
}
|
||||
// Empty audio is a text-only turn: nothing to play, no process to spawn.
|
||||
p.Play(audio.Audio{Format: audio.PCM16kMono})
|
||||
if p.Playing() {
|
||||
t.Fatal("empty audio started playback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAplayPlayerSatisfiesPlayer(t *testing.T) {
|
||||
var _ player = newAplayPlayer()
|
||||
var _ player = &fakePlayer{}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package main
|
||||
|
||||
// The capture session: what happens to one 30ms frame, given whether Maven is
|
||||
// currently speaking. Split out of main.go's processFrame so the decision is
|
||||
// testable without a mic, a speaker, or a daemon (Vikunja #287).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
|
||||
// utteranceSender ships one complete utterance to the voice server and
|
||||
// returns the reply audio to play. The real one round-trips over the voice
|
||||
// wire; tests substitute a recorder.
|
||||
type utteranceSender interface {
|
||||
Send(ctx context.Context, utt audio.Audio, lang string) (audio.Audio, error)
|
||||
}
|
||||
|
||||
// bargeInConfig holds the two numbers barge-in needs. Zero Frames disables
|
||||
// barge-in entirely — the half-duplex gate still runs.
|
||||
type bargeInConfig struct {
|
||||
// RMS is the normalised energy a frame must exceed to count as him
|
||||
// talking over her rather than the mic hearing her. It is deliberately
|
||||
// far above the VAD's own floor: the speaker leaks into the mic at
|
||||
// roughly ambient level, a person talking at the mic does not.
|
||||
RMS float64
|
||||
// Frames is how many consecutive frames must clear RMS before playback
|
||||
// is cut. One loud frame is a door closing; five in a row is a voice.
|
||||
Frames int
|
||||
}
|
||||
|
||||
// Enabled reports whether barge-in should be attempted at all.
|
||||
func (c bargeInConfig) Enabled() bool { return c.Frames > 0 && c.RMS > 0 }
|
||||
|
||||
// session is the per-client capture state machine.
|
||||
type session struct {
|
||||
vad *VAD
|
||||
player player
|
||||
sender utteranceSender
|
||||
lang string
|
||||
barge bargeInConfig
|
||||
|
||||
// now is the clock, swapped in tests. The round-trip backlog is measured
|
||||
// in wall time, because that is the only thing that says how much room
|
||||
// went into the pipe while the daemon was thinking.
|
||||
now func() time.Time
|
||||
|
||||
// discard is how many buffered frames still have to be thrown away
|
||||
// before capture means anything again. See dispatch.
|
||||
discard int
|
||||
|
||||
// recent holds the last few frames seen during playback, so the ones
|
||||
// that proved he was interrupting can be replayed into the VAD after the
|
||||
// barge-in reset instead of being clipped off the front of his sentence.
|
||||
recent [][]byte
|
||||
|
||||
// loudFrames counts consecutive over-threshold frames seen while she is
|
||||
// speaking. Reset whenever a frame falls back under the threshold, and
|
||||
// whenever playback ends.
|
||||
loudFrames int
|
||||
|
||||
// counters, read by tests and logged on the way out.
|
||||
suppressed int // frames dropped because she was speaking
|
||||
dropped int // frames dropped as round-trip backlog
|
||||
bargeIns int // times playback was cut because he spoke over her
|
||||
sent int // utterances shipped to the daemon
|
||||
|
||||
// loudSum and loudSeen accumulate the energy of suppressed frames, so
|
||||
// the operator can read what the room actually measures and set
|
||||
// -barge-in-rms from data instead of guessing.
|
||||
loudSum float64
|
||||
loudSeen int
|
||||
}
|
||||
|
||||
func newSession(vad *VAD, p player, s utteranceSender, lang string, barge bargeInConfig) *session {
|
||||
return &session{vad: vad, player: p, sender: s, lang: lang, barge: barge, now: time.Now}
|
||||
}
|
||||
|
||||
// frameDuration is the wall time one captured frame represents.
|
||||
const frameDuration = defaultFrameMs * time.Millisecond
|
||||
|
||||
// suppressLogEvery — how many suppressed frames between energy reports. 200
|
||||
// frames is six seconds of her talking, so this is roughly one line per reply.
|
||||
const suppressLogEvery = 200
|
||||
|
||||
// feed processes one 30ms PCM frame.
|
||||
//
|
||||
// While the player is running the capture side is muted: the VAD is not fed
|
||||
// and no utterance can be produced, so Maven's own reply cannot come back in
|
||||
// as a new command. The one thing that gets through is barge-in — sustained
|
||||
// energy well above the speaker's leak level cuts playback, and capture
|
||||
// resumes on the very next frame with a clean VAD.
|
||||
func (s *session) feed(ctx context.Context, frame []byte) error {
|
||||
// Backlog first, before anything looks at this frame. These are frames
|
||||
// the microphone captured while the round-trip blocked; they arrive in a
|
||||
// burst at pipe speed and they are not a command, not an answer and not
|
||||
// an interruption.
|
||||
if s.discard > 0 {
|
||||
s.discard--
|
||||
s.dropped++
|
||||
return nil
|
||||
}
|
||||
|
||||
if s.player.Playing() {
|
||||
s.suppressed++
|
||||
rms := frameRMS(PCMToI16(frame))
|
||||
s.loudSum += rms
|
||||
s.loudSeen++
|
||||
if s.loudSeen >= suppressLogEvery {
|
||||
// The doc comment asks for energy "well above the speaker's leak
|
||||
// level" and never says what that is. This is what it is.
|
||||
log.Printf("mavwaked: suppressed %d frames while speaking, mean rms %.4f (barge-in threshold %.4f)",
|
||||
s.loudSeen, s.loudSum/float64(s.loudSeen), s.barge.RMS)
|
||||
s.loudSum, s.loudSeen = 0, 0
|
||||
}
|
||||
if !s.barge.Enabled() {
|
||||
return nil
|
||||
}
|
||||
if rms < s.barge.RMS {
|
||||
s.loudFrames = 0
|
||||
s.recent = s.recent[:0]
|
||||
return nil
|
||||
}
|
||||
s.loudFrames++
|
||||
s.keepRecent(frame)
|
||||
if s.loudFrames < s.barge.Frames {
|
||||
return nil
|
||||
}
|
||||
// He is talking over her. Cut her off, drop the VAD state that
|
||||
// accumulated from the echo, and start listening for real — starting
|
||||
// with the frames that proved he was talking. Those used to be
|
||||
// thrown away, which clipped the first 150ms off his interruption,
|
||||
// and on a short one that is the whole first word.
|
||||
s.player.Stop()
|
||||
s.bargeIns++
|
||||
s.loudFrames = 0
|
||||
s.vad.Reset()
|
||||
log.Printf("mavwaked: barge-in — stopped playback")
|
||||
s.replayRecent()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Not speaking. If we just stopped, make sure no echo-era state leaks
|
||||
// into the next utterance.
|
||||
if s.loudFrames != 0 {
|
||||
s.loudFrames = 0
|
||||
s.vad.Reset()
|
||||
}
|
||||
|
||||
utt, state := s.vad.Feed(PCMToI16(frame))
|
||||
if state == StateSpeech || utt.Bytes == nil {
|
||||
return nil
|
||||
}
|
||||
return s.dispatch(ctx, utt)
|
||||
}
|
||||
|
||||
// keepRecent stores a copy of one barge-in trigger frame, keeping at most
|
||||
// barge.Frames of them.
|
||||
func (s *session) keepRecent(frame []byte) {
|
||||
if len(s.recent) >= s.barge.Frames {
|
||||
copy(s.recent, s.recent[1:])
|
||||
s.recent = s.recent[:len(s.recent)-1]
|
||||
}
|
||||
s.recent = append(s.recent, append([]byte(nil), frame...))
|
||||
}
|
||||
|
||||
// replayRecent feeds the trigger frames back into the freshly reset VAD, so
|
||||
// his interruption starts where he started it.
|
||||
//
|
||||
// Feed cannot complete an utterance here: closing one needs silenceMs of
|
||||
// trailing quiet and these frames are all above the barge-in threshold, which
|
||||
// is far above the VAD floor. Any utterance it did return would be a fragment
|
||||
// of a sentence he is still speaking, so it is not dispatched.
|
||||
func (s *session) replayRecent() {
|
||||
for _, f := range s.recent {
|
||||
s.vad.Feed(PCMToI16(f))
|
||||
}
|
||||
s.recent = s.recent[:0]
|
||||
}
|
||||
|
||||
// dispatch ships a complete utterance and plays whatever comes back.
|
||||
//
|
||||
// Every return path here has to deal with the backlog. Nothing reads the
|
||||
// microphone while Send is in flight, so the audio piles up in arecord's pipe
|
||||
// and the kernel buffer, and it arrives in a burst the moment this returns. A
|
||||
// round-trip is p50 2.7s through the LLM router, which is around 90 frames of
|
||||
// room, of him finishing his sentence, of the television.
|
||||
//
|
||||
// This used to reset the VAD on the reply path only, and for the wrong reason:
|
||||
// the comment said the VAD had been accumulating during the round-trip, when
|
||||
// in fact its state is exactly what Feed left it as. The two paths that had no
|
||||
// reset are the ones that mattered, because neither of them starts playback
|
||||
// and so neither is covered by the half-duplex gate. A text-only turn fed the
|
||||
// whole backlog straight into the VAD, and a Send error did the same on every
|
||||
// failed turn, so a dead socket drove a retry loop off nothing but backlog.
|
||||
func (s *session) dispatch(ctx context.Context, utt audio.Audio) error {
|
||||
log.Printf("mavwaked: utterance complete (%.2fs, %d bytes), sending...", utt.Duration(), len(utt.Bytes))
|
||||
start := s.now()
|
||||
reply, err := s.sender.Send(ctx, utt, s.lang)
|
||||
defer s.dropBacklog(start)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Count what was shipped, not what was attempted. This used to run
|
||||
// before the error check, so failed round-trips counted as sent.
|
||||
s.sent++
|
||||
if len(reply.Bytes) == 0 {
|
||||
log.Printf("mavwaked: empty reply audio (text only)")
|
||||
return nil
|
||||
}
|
||||
s.player.Play(reply)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dropBacklog resets the VAD and arranges for the frames captured during the
|
||||
// round-trip to be thrown away as they arrive.
|
||||
//
|
||||
// Discarding them is also what keeps barge-in honest. The Frames guard is
|
||||
// documented as "long enough that a door or a cough does not cut her off",
|
||||
// which assumes the frames are real time. Draining a backlog delivers five
|
||||
// frames in microseconds, so without this she could be cut off by audio
|
||||
// recorded before she started speaking.
|
||||
func (s *session) dropBacklog(start time.Time) {
|
||||
s.vad.Reset()
|
||||
s.loudFrames = 0
|
||||
s.recent = s.recent[:0]
|
||||
if elapsed := s.now().Sub(start); elapsed > 0 {
|
||||
s.discard = int(elapsed / frameDuration)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
)
|
||||
|
||||
// fakePlayer records Play/Stop instead of shelling out to aplay.
|
||||
type fakePlayer struct {
|
||||
playing bool
|
||||
plays int
|
||||
stops int
|
||||
last audio.Audio
|
||||
}
|
||||
|
||||
func (p *fakePlayer) Play(a audio.Audio) { p.playing = true; p.plays++; p.last = a }
|
||||
func (p *fakePlayer) Stop() { p.playing = false; p.stops++ }
|
||||
func (p *fakePlayer) Playing() bool { return p.playing }
|
||||
|
||||
// fakeSender records what was shipped and hands back a canned reply.
|
||||
type fakeSender struct {
|
||||
sent []audio.Audio
|
||||
reply audio.Audio
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *fakeSender) Send(_ context.Context, utt audio.Audio, _ string) (audio.Audio, error) {
|
||||
s.sent = append(s.sent, utt)
|
||||
return s.reply, s.err
|
||||
}
|
||||
|
||||
func replyAudio() audio.Audio {
|
||||
return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 16000)}
|
||||
}
|
||||
|
||||
// frameAt returns a 30ms frame whose RMS is approximately rms.
|
||||
func frameAt(rms float64) []byte {
|
||||
amp := rms * math.Sqrt2 * 32768
|
||||
f := make([]int16, frameSamples)
|
||||
for i := range f {
|
||||
f[i] = int16(amp * math.Sin(2*math.Pi*440*float64(i)/16000))
|
||||
}
|
||||
return pcmBytes(f)
|
||||
}
|
||||
|
||||
func silentBytes() []byte { return make([]byte, frameSamples*2) }
|
||||
|
||||
// newTestSession wires a session with fakes and a default VAD.
|
||||
func newTestSession(barge bargeInConfig) (*session, *fakePlayer, *fakeSender) {
|
||||
p := &fakePlayer{}
|
||||
s := &fakeSender{reply: replyAudio()}
|
||||
return newSession(NewVAD(0, 0, 0, 0), p, s, "ru", barge), p, s
|
||||
}
|
||||
|
||||
// speakThenPause drives a full utterance through the session: enough loud
|
||||
// frames to trigger, then enough silence to end it.
|
||||
func speakThenPause(t *testing.T, sess *session) {
|
||||
t.Helper()
|
||||
speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs
|
||||
silenceFrames := (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs + 2
|
||||
loud := frameAt(0.35)
|
||||
for i := 0; i < speechFrames+5; i++ {
|
||||
if err := sess.feed(context.Background(), loud); err != nil {
|
||||
t.Fatalf("feed loud frame %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < silenceFrames; i++ {
|
||||
if err := sess.feed(context.Background(), silentBytes()); err != nil {
|
||||
t.Fatalf("feed silent frame %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionSendsUtteranceAndPlaysReply(t *testing.T) {
|
||||
sess, p, snd := newTestSession(bargeInConfig{})
|
||||
speakThenPause(t, sess)
|
||||
|
||||
if len(snd.sent) != 1 {
|
||||
t.Fatalf("sent %d utterances, want 1", len(snd.sent))
|
||||
}
|
||||
if snd.sent[0].Format != audio.PCM16kMono {
|
||||
t.Errorf("utterance format = %+v, want canonical", snd.sent[0].Format)
|
||||
}
|
||||
if p.plays != 1 {
|
||||
t.Errorf("plays = %d, want 1", p.plays)
|
||||
}
|
||||
}
|
||||
|
||||
// The bug this whole file exists for: while the speaker is running, the mic
|
||||
// hears Maven and the old code shipped that back as a fresh command.
|
||||
func TestSessionDoesNotHearItselfWhilePlaying(t *testing.T) {
|
||||
sess, p, snd := newTestSession(bargeInConfig{})
|
||||
speakThenPause(t, sess)
|
||||
if !p.Playing() {
|
||||
t.Fatal("expected playback to be running after the reply")
|
||||
}
|
||||
|
||||
// Feed a long stretch of loud audio — Maven's own voice coming back in.
|
||||
base := sess.suppressed
|
||||
loud := frameAt(0.35)
|
||||
for i := 0; i < 200; i++ {
|
||||
if err := sess.feed(context.Background(), loud); err != nil {
|
||||
t.Fatalf("feed echo frame %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(snd.sent) != 1 {
|
||||
t.Fatalf("sent %d utterances, want 1 — her own reply was captured as a command", len(snd.sent))
|
||||
}
|
||||
if got := sess.suppressed - base; got != 200 {
|
||||
t.Errorf("suppressed %d of the 200 echo frames, want all of them", got)
|
||||
}
|
||||
if p.stops != 0 {
|
||||
t.Errorf("stops = %d, want 0 — barge-in is off, nothing should cut her off", p.stops)
|
||||
}
|
||||
}
|
||||
|
||||
// With barge-in off, no amount of noise stops playback.
|
||||
func TestSessionBargeInDisabledByDefault(t *testing.T) {
|
||||
sess, p, _ := newTestSession(bargeInConfig{})
|
||||
if sess.barge.Enabled() {
|
||||
t.Fatal("zero bargeInConfig must be disabled")
|
||||
}
|
||||
speakThenPause(t, sess)
|
||||
veryLoud := frameAt(0.6)
|
||||
for i := 0; i < 50; i++ {
|
||||
_ = sess.feed(context.Background(), veryLoud)
|
||||
}
|
||||
if p.stops != 0 || sess.bargeIns != 0 {
|
||||
t.Fatalf("stops = %d, bargeIns = %d, want 0 with barge-in off", p.stops, sess.bargeIns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionBargeInCutsPlayback(t *testing.T) {
|
||||
barge := bargeInConfig{RMS: 0.12, Frames: 5}
|
||||
sess, p, _ := newTestSession(barge)
|
||||
speakThenPause(t, sess)
|
||||
if !p.Playing() {
|
||||
t.Fatal("expected playback after the reply")
|
||||
}
|
||||
|
||||
// Four loud frames must not be enough — a door closing is not a voice.
|
||||
veryLoud := frameAt(0.35)
|
||||
for i := 0; i < 4; i++ {
|
||||
_ = sess.feed(context.Background(), veryLoud)
|
||||
}
|
||||
if p.stops != 0 {
|
||||
t.Fatalf("playback cut after 4 frames, want it to hold until %d", barge.Frames)
|
||||
}
|
||||
|
||||
// The fifth cuts her off.
|
||||
_ = sess.feed(context.Background(), veryLoud)
|
||||
if p.stops != 1 || sess.bargeIns != 1 {
|
||||
t.Fatalf("stops = %d, bargeIns = %d, want 1 and 1", p.stops, sess.bargeIns)
|
||||
}
|
||||
if p.Playing() {
|
||||
t.Fatal("still playing after barge-in")
|
||||
}
|
||||
}
|
||||
|
||||
// A burst that falls back under the threshold resets the counter, so noise
|
||||
// spread over a whole reply never accumulates into a false barge-in.
|
||||
func TestSessionBargeInNeedsConsecutiveFrames(t *testing.T) {
|
||||
sess, p, _ := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
|
||||
speakThenPause(t, sess)
|
||||
|
||||
veryLoud := frameAt(0.35)
|
||||
quiet := frameAt(0.02)
|
||||
for i := 0; i < 20; i++ {
|
||||
_ = sess.feed(context.Background(), veryLoud)
|
||||
_ = sess.feed(context.Background(), veryLoud)
|
||||
_ = sess.feed(context.Background(), quiet)
|
||||
}
|
||||
if p.stops != 0 || sess.bargeIns != 0 {
|
||||
t.Fatalf("stops = %d, bargeIns = %d, want 0 — two-frame bursts must not accumulate", p.stops, sess.bargeIns)
|
||||
}
|
||||
}
|
||||
|
||||
// Speaker leak sits near the room floor; it must never reach the barge-in bar.
|
||||
func TestSessionEchoLevelAudioNeverBargesIn(t *testing.T) {
|
||||
sess, p, _ := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
|
||||
speakThenPause(t, sess)
|
||||
|
||||
base := sess.suppressed
|
||||
leak := frameAt(0.05) // loud enough for the VAD, far under the barge bar
|
||||
for i := 0; i < 300; i++ {
|
||||
_ = sess.feed(context.Background(), leak)
|
||||
}
|
||||
if p.stops != 0 {
|
||||
t.Fatalf("stops = %d, want 0 — speaker leak must not read as barge-in", p.stops)
|
||||
}
|
||||
if got := sess.suppressed - base; got != 300 {
|
||||
t.Errorf("suppressed %d of the 300 leak frames, want all of them", got)
|
||||
}
|
||||
}
|
||||
|
||||
// After barge-in the VAD must start clean, so the interrupting speech is
|
||||
// captured as a whole utterance rather than joined onto echo state.
|
||||
func TestSessionCapturesTheInterruptingUtterance(t *testing.T) {
|
||||
sess, p, snd := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
|
||||
speakThenPause(t, sess)
|
||||
|
||||
veryLoud := frameAt(0.35)
|
||||
for i := 0; i < 5; i++ {
|
||||
_ = sess.feed(context.Background(), veryLoud)
|
||||
}
|
||||
if p.stops != 1 {
|
||||
t.Fatalf("expected barge-in, stops = %d", p.stops)
|
||||
}
|
||||
|
||||
// He keeps talking; that is a new command.
|
||||
speakThenPause(t, sess)
|
||||
if len(snd.sent) != 2 {
|
||||
t.Fatalf("sent %d utterances, want 2 — the interruption itself must be heard", len(snd.sent))
|
||||
}
|
||||
if p.plays != 2 {
|
||||
t.Errorf("plays = %d, want 2", p.plays)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed round-trip must surface as an error and must not start playback.
|
||||
func TestSessionSendErrorDoesNotPlay(t *testing.T) {
|
||||
p := &fakePlayer{}
|
||||
snd := &fakeSender{err: errors.New("boom")}
|
||||
sess := newSession(NewVAD(0, 0, 0, 0), p, snd, "ru", bargeInConfig{})
|
||||
|
||||
speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs
|
||||
silenceFrames := (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs + 2
|
||||
loud := frameAt(0.35)
|
||||
var lastErr error
|
||||
for i := 0; i < speechFrames+5; i++ {
|
||||
_ = sess.feed(context.Background(), loud)
|
||||
}
|
||||
for i := 0; i < silenceFrames; i++ {
|
||||
if err := sess.feed(context.Background(), silentBytes()); err != nil {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
t.Fatal("send error was swallowed")
|
||||
}
|
||||
if p.plays != 0 || p.Playing() {
|
||||
t.Fatalf("plays = %d, playing = %v, want no playback on a failed round-trip", p.plays, p.Playing())
|
||||
}
|
||||
}
|
||||
|
||||
// An empty reply (text-only turn) must leave the capture side open.
|
||||
func TestSessionEmptyReplyLeavesCaptureOpen(t *testing.T) {
|
||||
p := &fakePlayer{}
|
||||
snd := &fakeSender{reply: audio.Audio{Format: audio.PCM16kMono}}
|
||||
sess := newSession(NewVAD(0, 0, 0, 0), p, snd, "ru", bargeInConfig{})
|
||||
|
||||
speakThenPause(t, sess)
|
||||
if p.plays != 0 {
|
||||
t.Fatalf("plays = %d, want 0 for an empty reply", p.plays)
|
||||
}
|
||||
speakThenPause(t, sess)
|
||||
if len(snd.sent) != 2 {
|
||||
t.Fatalf("sent %d, want 2 — capture must stay open when there is no audio reply", len(snd.sent))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBargeInConfigEnabled(t *testing.T) {
|
||||
cases := []struct {
|
||||
c bargeInConfig
|
||||
want bool
|
||||
}{
|
||||
{bargeInConfig{}, false},
|
||||
{bargeInConfig{RMS: 0.12}, false},
|
||||
{bargeInConfig{Frames: 5}, false},
|
||||
{bargeInConfig{RMS: 0.12, Frames: 5}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := tc.c.Enabled(); got != tc.want {
|
||||
t.Errorf("%+v.Enabled() = %v, want %v", tc.c, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// slowSender models the real thing: a round-trip takes wall-clock time, and
|
||||
// the microphone keeps recording into a pipe nobody is reading.
|
||||
type slowSender struct {
|
||||
fakeSender
|
||||
clock *time.Time
|
||||
took time.Duration
|
||||
}
|
||||
|
||||
func (s *slowSender) Send(ctx context.Context, utt audio.Audio, lang string) (audio.Audio, error) {
|
||||
*s.clock = s.clock.Add(s.took)
|
||||
return s.fakeSender.Send(ctx, utt, lang)
|
||||
}
|
||||
|
||||
// newSlowSession wires a session whose round-trip takes took of wall time.
|
||||
func newSlowSession(barge bargeInConfig, reply audio.Audio, err error, took time.Duration) (*session, *fakePlayer, *slowSender) {
|
||||
now := time.Unix(0, 0)
|
||||
p := &fakePlayer{}
|
||||
snd := &slowSender{fakeSender: fakeSender{reply: reply, err: err}, clock: &now, took: took}
|
||||
sess := newSession(NewVAD(0, 0, 0, 0), p, snd, "ru", barge)
|
||||
sess.now = func() time.Time { return now }
|
||||
return sess, p, snd
|
||||
}
|
||||
|
||||
// A text-only turn starts no playback, so the half-duplex gate does not cover
|
||||
// it. The backlog captured during the round-trip has to be dropped anyway, or
|
||||
// three seconds of room arrives at pipe speed and becomes a command.
|
||||
func TestSessionDropsBacklogAfterAnEmptyReply(t *testing.T) {
|
||||
sess, p, snd := newSlowSession(bargeInConfig{}, audio.Audio{Format: audio.PCM16kMono}, nil, 3*time.Second)
|
||||
|
||||
speakThenPause(t, sess)
|
||||
if p.plays != 0 || len(snd.sent) != 1 {
|
||||
t.Fatalf("plays = %d, sent = %d; want one text-only turn", p.plays, len(snd.sent))
|
||||
}
|
||||
// The tail of speakThenPause already spent a couple of them.
|
||||
if want := int(3 * time.Second / frameDuration); sess.discard+sess.dropped != want {
|
||||
t.Fatalf("discard %d + dropped %d frames, want %d (3s of backlog)", sess.discard, sess.dropped, want)
|
||||
}
|
||||
|
||||
// The burst: the whole backlog, all of it him still talking.
|
||||
loud := frameAt(0.35)
|
||||
for i := 0; i < sess.discard; i++ {
|
||||
if err := sess.feed(context.Background(), loud); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(snd.sent) != 1 {
|
||||
t.Errorf("the backlog was sent as a second utterance (sent = %d)", len(snd.sent))
|
||||
}
|
||||
if sess.dropped == 0 {
|
||||
t.Error("no frames were counted as backlog")
|
||||
}
|
||||
}
|
||||
|
||||
// Same on the error path. A dead daemon used to seed the next spurious trigger
|
||||
// on every failed turn, so a dead socket drove a retry loop off backlog alone.
|
||||
func TestSessionDropsBacklogAfterASendError(t *testing.T) {
|
||||
sess, _, _ := newSlowSession(bargeInConfig{}, audio.Audio{}, errors.New("boom"), 3*time.Second)
|
||||
|
||||
// Not speakThenPause: the dispatch returns the send error, which that
|
||||
// helper treats as fatal.
|
||||
loud := frameAt(0.35)
|
||||
for i := 0; i < (defaultSpeechMs+defaultFrameMs-1)/defaultFrameMs+5; i++ {
|
||||
_ = sess.feed(context.Background(), loud)
|
||||
}
|
||||
for i := 0; i < (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs+2; i++ {
|
||||
_ = sess.feed(context.Background(), silentBytes())
|
||||
}
|
||||
if sess.discard == 0 {
|
||||
t.Fatal("a failed round-trip left the backlog to be fed into the VAD")
|
||||
}
|
||||
}
|
||||
|
||||
// Barge-in must not be triggerable by the backlog. Those frames are him
|
||||
// finishing the sentence he started before she answered, delivered in
|
||||
// microseconds, and the five-frame guard assumes real time.
|
||||
func TestSessionBacklogCannotBargeIn(t *testing.T) {
|
||||
sess, p, _ := newSlowSession(bargeInConfig{RMS: 0.12, Frames: 5}, replyAudio(), nil, 3*time.Second)
|
||||
|
||||
speakThenPause(t, sess)
|
||||
if p.plays != 1 || !p.Playing() {
|
||||
t.Fatalf("plays = %d, playing = %v; want the reply playing", p.plays, p.Playing())
|
||||
}
|
||||
|
||||
loud := frameAt(0.35)
|
||||
backlog := sess.discard
|
||||
if backlog < 5 {
|
||||
t.Fatalf("discard = %d, want a real backlog", backlog)
|
||||
}
|
||||
for i := 0; i < backlog; i++ {
|
||||
if err := sess.feed(context.Background(), loud); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if p.stops != 0 {
|
||||
t.Fatalf("she was cut off by audio recorded before she started speaking (stops = %d)", p.stops)
|
||||
}
|
||||
|
||||
// Real-time speech after the backlog still interrupts her.
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := sess.feed(context.Background(), loud); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if p.stops != 1 {
|
||||
t.Fatalf("stops = %d, want 1 — barge-in must still work after the backlog", p.stops)
|
||||
}
|
||||
}
|
||||
|
||||
// The frames that proved he was interrupting are replayed into the VAD, so his
|
||||
// first word is not clipped. Five trigger frames plus five real ones reach the
|
||||
// 300ms speech threshold; without the replay the first five are lost and no
|
||||
// utterance is produced at all.
|
||||
func TestSessionReplaysTheBargeInTriggerFrames(t *testing.T) {
|
||||
sess, p, snd := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
|
||||
speakThenPause(t, sess)
|
||||
|
||||
veryLoud := frameAt(0.35)
|
||||
for i := 0; i < 5; i++ {
|
||||
_ = sess.feed(context.Background(), veryLoud)
|
||||
}
|
||||
if p.stops != 1 {
|
||||
t.Fatalf("expected barge-in, stops = %d", p.stops)
|
||||
}
|
||||
if p.Playing() {
|
||||
t.Fatal("fake player still playing after Stop")
|
||||
}
|
||||
|
||||
speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs
|
||||
for i := 0; i < speechFrames-5; i++ {
|
||||
if err := sess.feed(context.Background(), veryLoud); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
silenceFrames := (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs + 2
|
||||
for i := 0; i < silenceFrames; i++ {
|
||||
if err := sess.feed(context.Background(), silentBytes()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if len(snd.sent) != 2 {
|
||||
t.Fatalf("sent %d utterances, want 2 — the 150ms that triggered barge-in was clipped", len(snd.sent))
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,15 @@ const (
|
||||
defaultSilenceMs = 800 // silence hold before declaring end-of-utterance
|
||||
defaultMaxMs = 10000 // cap single utterance at 10s
|
||||
defaultMinRMS = 0.01 // RMS floor (same as mavsttd)
|
||||
|
||||
// Barge-in thresholds. Only used when -barge-in is passed. The RMS is
|
||||
// x10000 like -min-rms, and sits an order of magnitude above the VAD's
|
||||
// own floor on purpose: with no acoustic echo canceller, a frame only
|
||||
// counts as "he is talking over her" if it is far louder than what the
|
||||
// speaker leaks back into the mic. 5 frames is 150ms — long enough that
|
||||
// a door or a cough does not cut her off mid-sentence.
|
||||
defaultBargeRMS = 1200 // 0.12 normalised RMS
|
||||
defaultBargeFrames = 5
|
||||
)
|
||||
|
||||
// frameSamples — samples per 30ms frame at 16kHz.
|
||||
|
||||
+16
-1
@@ -30,6 +30,13 @@ import (
|
||||
// A notification with no recognisable clock reading stores NOTHING. Maven is
|
||||
// not a guesser-of-truth, and a mailbox of noise rendered as invented meetings
|
||||
// is worse than a gap.
|
||||
//
|
||||
// KNOWN GAP: this writes calendar_event_* and nothing else, so an ambient
|
||||
// meeting is good enough to recite and not good enough to stop a nudge —
|
||||
// calendar_busy is still written only by the CalDAV poller. That is backwards,
|
||||
// since suppressing a nudge is the lower-risk use of a low-confidence signal.
|
||||
// calendar_busy is a level rather than an event, so an ambient writer needs an
|
||||
// expiry, which is its own task and not a change here.
|
||||
|
||||
// ambientMaxBody bounds the request. A notification is two short lines.
|
||||
const ambientMaxBody = 8 << 10
|
||||
@@ -120,8 +127,16 @@ func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, tok
|
||||
|
||||
// ambientAuthorized accepts the token as a bearer header or as an X-Maven-Token
|
||||
// header, compared in constant time.
|
||||
//
|
||||
// The scheme is matched case-insensitively. RFC 7235 says it is, and a phone
|
||||
// client sending "bearer <tok>" used to fall through to the X-Maven-Token
|
||||
// branch and get a silent 401 with nothing to see from the phone's side.
|
||||
func ambientAuthorized(r *http.Request, token string) bool {
|
||||
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
|
||||
got := ""
|
||||
if authz := strings.TrimSpace(r.Header.Get("Authorization")); len(authz) >= len("Bearer") &&
|
||||
strings.EqualFold(authz[:len("Bearer")], "Bearer") {
|
||||
got = strings.TrimSpace(authz[len("Bearer"):])
|
||||
}
|
||||
if got == "" {
|
||||
got = strings.TrimSpace(r.Header.Get("X-Maven-Token"))
|
||||
}
|
||||
|
||||
@@ -55,7 +55,10 @@ func meetingNotification() calendar.Notification {
|
||||
Package: "com.google.android.gm",
|
||||
Title: "Планёрка",
|
||||
Text: "10:00-10:30",
|
||||
Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.UTC),
|
||||
// Local, like a phone relaying from the box's own timezone: the fact
|
||||
// key and value are stamped on the owner's clock, so a UTC reading
|
||||
// here would only be testing the offset of the test machine.
|
||||
Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +169,27 @@ func TestHandleAmbientAuth(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// RFC 7235 says the scheme is case-insensitive. A phone sending
|
||||
// "bearer <tok>" used to fall through to the X-Maven-Token branch and get a
|
||||
// 401 that looked, from the phone's side, like a wrong token.
|
||||
t.Run("lowercase bearer scheme accepted", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handleAmbient(rr, newReq("Authorization", "bearer "+ambientTestToken), &ambientCore{}, ambientTestToken)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Errorf("status = %d, want 201: %s", rr.Code, rr.Body)
|
||||
}
|
||||
})
|
||||
|
||||
// A bare token with no scheme is not a bearer header. Accepting it made the
|
||||
// Authorization branch a second, undocumented X-Maven-Token.
|
||||
t.Run("bare token in Authorization rejected", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handleAmbient(rr, newReq("Authorization", ambientTestToken), &ambientCore{}, ambientTestToken)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("X-Maven-Token accepted", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handleAmbient(rr, newReq("X-Maven-Token", ambientTestToken), &ambientCore{}, ambientTestToken)
|
||||
|
||||
+16
-1
@@ -8,6 +8,8 @@ import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// The three sibling services Maven coordinates are headless JSON APIs (no web UI
|
||||
@@ -84,9 +86,13 @@ type ecoData struct {
|
||||
Nexus ecoPanel[ecoEntity]
|
||||
Praxis ecoPanel[ecoItem]
|
||||
Hexis ecoPanel[ecoCap]
|
||||
Calls ecoPanel[ipc.EcosystemTrace]
|
||||
}
|
||||
|
||||
func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs) {
|
||||
// handleEcosystem renders the three sibling panels plus Maven's own log of the
|
||||
// calls she made to them. The call log comes from core, not from the siblings:
|
||||
// it is what Maven saw, including the hops that never got an answer.
|
||||
func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs, core ipc.CoreAPI) {
|
||||
ctx := r.Context()
|
||||
var d ecoData
|
||||
var wg sync.WaitGroup
|
||||
@@ -102,6 +108,15 @@ func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs) {
|
||||
go func() { defer wg.Done(); d.Hexis.Err = getEco(ctx, urls.hexis, "/api/v1/capabilities", &d.Hexis.Rows) }()
|
||||
wg.Wait()
|
||||
|
||||
if core == nil {
|
||||
d.Calls.Err = "not configured"
|
||||
} else if rows, err := core.RecentEcosystemTraces(ctx, 50); err != nil {
|
||||
log.Printf("ecosystem traces: %v", err)
|
||||
d.Calls.Err = "core read failed"
|
||||
} else {
|
||||
d.Calls.Rows = rows
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := ecosystemTmpl.Execute(w, d); err != nil {
|
||||
log.Printf("ecosystem render: %v", err)
|
||||
|
||||
@@ -41,11 +41,24 @@
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class=card id=eco-calls>
|
||||
<div class=section-header>
|
||||
<h2>Calls <span class=card-sub>what Maven asked them</span></h2>
|
||||
</div>
|
||||
{{with .Calls}}
|
||||
{{if .Err}}<div class=empty>calls — {{.Err}}</div>
|
||||
{{else if not .Rows}}<div class=empty>no ecosystem calls yet.</div>
|
||||
{{else}}<div class=scroll><table class=mono><tr><th>when<th>service<th>operation<th>status<th>ms<th>http<th>correlation</tr>
|
||||
{{range .Rows}}<tr><td>{{ago .Ts}}<td><span class=badge>{{.Service}}</span><td class=en>{{.Operation}}<td>{{if eq .Status "ok"}}<span class="badge badge-ok">ok</span>{{else}}<span class="badge badge-warn">{{.Status}}</span>{{end}}<td>{{.DurationMs}}<td>{{if .HTTPStatus}}{{.HTTPStatus}}{{else}}—{{end}}<td class=key>{{.CorrelationID}}</tr>{{end}}
|
||||
</table></div>{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{template "shellBottom"}}
|
||||
<script>
|
||||
setInterval(() => fetch('/ecosystem').then(r => r.text()).then(html => {
|
||||
const d = new DOMParser().parseFromString(html, 'text/html');
|
||||
for (const id of ['eco-nexus', 'eco-praxis', 'eco-hexis']) {
|
||||
for (const id of ['eco-nexus', 'eco-praxis', 'eco-hexis', 'eco-calls']) {
|
||||
const old = document.getElementById(id), nu = d.getElementById(id);
|
||||
if (old && nu) old.replaceWith(nu);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{{template "shellTop" "events"}}
|
||||
<h1>Intake</h1>
|
||||
<div class=hint>Everything that arrived, most recently noticed first — a relayed notification, a mail
|
||||
candidate, a feed item, a changed page, a spend, a presence probe. Maven's own bookkeeping writes (feed
|
||||
watermarks, crawl hashes, act traces, settings he toggled) are not here: nothing arrived. <b>noticed</b>
|
||||
is when the journal saw it, <b>happened</b> is when the thing itself did, and those differ by days on a
|
||||
cold feed read. One envelope per write; the durable row is still the fact, note or task itself. Held in
|
||||
memory only, so a restart empties this.</div>
|
||||
{{if .Err}}<div class=hint>journal unavailable: {{.Err}}</div>{{end}}
|
||||
{{if and (not .Events) (not .Err)}}
|
||||
<div class=hint>nothing has arrived yet</div>
|
||||
{{end}}
|
||||
{{if .Events}}
|
||||
<div class=scroll><table class=mono>
|
||||
<tr><th>noticed<th>happened<th>source<th>kind<th>pri<th>what<th>detail</tr>
|
||||
{{range .Events}}<tr>
|
||||
<td>{{.NoticedAt.Format "02.01 15:04:05"}}</td>
|
||||
<td class=gray>{{.OccurredAt.Format "02.01 15:04:05"}}</td>
|
||||
<td class=gray>{{.Source}}</td>
|
||||
<td class=gray>{{.Kind}}</td>
|
||||
<td class=gray>{{.Priority}}</td>
|
||||
<td>{{.Title}}</td>
|
||||
<td class=gray>{{.Body}}</td>
|
||||
</tr>{{end}}
|
||||
</table></div>
|
||||
{{end}}
|
||||
{{template "shellBottom"}}
|
||||
</html>
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// eventsCore serves a canned intake journal. Embedding
|
||||
// ipc.UnimplementedCoreAPI means any other call fails loudly.
|
||||
type eventsCore struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
events []ipc.IntakeEvent
|
||||
err error
|
||||
gotN int
|
||||
}
|
||||
|
||||
func (c *eventsCore) RecentEvents(_ context.Context, n int) ([]ipc.IntakeEvent, error) {
|
||||
c.gotN = n
|
||||
return c.events, c.err
|
||||
}
|
||||
|
||||
func getEvents(t *testing.T, core ipc.CoreAPI) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
handleEvents(w, httptest.NewRequest(http.MethodGet, "/events", nil), core)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestEventsPageRendersTheJournal(t *testing.T) {
|
||||
core := &eventsCore{events: []ipc.IntakeEvent{
|
||||
{Source: "rss:tech", Kind: "note", Title: "Вышло ядро 6.19", Priority: "low",
|
||||
OccurredAt: time.Date(2026, 8, 1, 7, 15, 0, 0, time.UTC)},
|
||||
{Source: "ambient:notif", Kind: "fact", Title: "calendar_event_20260801_планёрка",
|
||||
Body: "10:00-11:00 планёрка", Priority: "low",
|
||||
OccurredAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)},
|
||||
}}
|
||||
w := getEvents(t, core)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", "01.08 10:00:00"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("page does not mention %q", want)
|
||||
}
|
||||
}
|
||||
if core.gotN != eventsPageLimit {
|
||||
t.Errorf("asked core for %d events, want %d", core.gotN, eventsPageLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsPageSaysNothingArrived(t *testing.T) {
|
||||
w := getEvents(t, &eventsCore{})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "nothing has arrived yet") {
|
||||
t.Error("empty journal did not render the empty-state line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsPageReportsAReadFailure(t *testing.T) {
|
||||
// An unreachable journal must say so rather than render an empty table,
|
||||
// which would imply nothing arrived.
|
||||
w := getEvents(t, &eventsCore{err: errors.New("core is down")})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 with the error rendered", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "journal unavailable") || !strings.Contains(body, "core is down") {
|
||||
t.Errorf("page did not report the read failure: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "nothing has arrived yet") {
|
||||
t.Error("a failed read rendered as an empty journal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsPageWithoutCore(t *testing.T) {
|
||||
w := getEvents(t, nil)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("status = %d, want 503", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsPageEscapesIntakeText(t *testing.T) {
|
||||
// Titles come from outside — a feed headline, a notification. They are shown
|
||||
// on a page and must never be able to inject markup into it.
|
||||
core := &eventsCore{events: []ipc.IntakeEvent{{
|
||||
Source: "rss:x", Kind: "note", Priority: "low",
|
||||
Title: `<script>alert(1)</script>`,
|
||||
OccurredAt: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
|
||||
}}}
|
||||
body := getEvents(t, core).Body.String()
|
||||
if strings.Contains(body, "<script>alert(1)</script>") {
|
||||
t.Error("intake title was not escaped")
|
||||
}
|
||||
if !strings.Contains(body, "<script>") {
|
||||
t.Error("intake title is missing from the page entirely")
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,14 @@ type fakeCore struct {
|
||||
// for handleChatAPI tests
|
||||
chatText string
|
||||
chatErr error
|
||||
|
||||
// for the MCP section of /tools
|
||||
mcpServers []ipc.MCPServerStatus
|
||||
mcpErr error
|
||||
}
|
||||
|
||||
func (f *fakeCore) MCPServers(context.Context) ([]ipc.MCPServerStatus, error) {
|
||||
return f.mcpServers, f.mcpErr
|
||||
}
|
||||
|
||||
func (f *fakeCore) Chat(_ context.Context, text string) (string, error) {
|
||||
@@ -1118,3 +1126,128 @@ func TestHandleChatAPI_FailOpenByDefault(t *testing.T) {
|
||||
t.Errorf("core.Chat text = %q, want %q", core.chatText, "привет")
|
||||
}
|
||||
}
|
||||
|
||||
// The MCP section renders the configured servers, and a proposal that already
|
||||
// knows its cmd prefills the enable form so the argv is not retyped by hand.
|
||||
func TestHandleTools_GET_MCPSection(t *testing.T) {
|
||||
core := &fakeCore{
|
||||
proposed: []ipc.Tool{{
|
||||
Name: "vikunja_list_tasks", Scope: "mcp:vikunja",
|
||||
Cmd: []string{"mcp", "vikunja", "list_tasks"}, Destructive: true,
|
||||
Utterance: "mcp vikunja/list_tasks: List tasks in a project.",
|
||||
}},
|
||||
mcpServers: []ipc.MCPServerStatus{
|
||||
{Name: "vikunja", Transport: "http", Target: "http://192.168.1.104:9100/mcp", Connected: true, Server: "vikunja 0.1.0", Tools: 4},
|
||||
{Name: "files", Transport: "stdio", Target: "mcp-server-fs /srv", Err: "start: no such file"},
|
||||
},
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core, nil, false)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{
|
||||
"MCP servers", "vikunja", "192.168.1.104:9100/mcp", "vikunja 0.1.0",
|
||||
"files", "no such file",
|
||||
`value="mcp vikunja list_tasks"`, // the enable form is prefilled
|
||||
"checked", // and pre-marked destructive (no readOnlyHint)
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("missing %q in /tools output", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MCP off (or an older core that does not know the method) renders the section
|
||||
// empty instead of breaking the page.
|
||||
func TestHandleTools_GET_MCPUnavailable(t *testing.T) {
|
||||
core := &fakeCore{mcpErr: ipc.ErrNotImplemented}
|
||||
rr := httptest.NewRecorder()
|
||||
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core, nil, false)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rr.Code)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), "no MCP servers configured") {
|
||||
t.Error("expected the empty-state copy")
|
||||
}
|
||||
}
|
||||
|
||||
// --- voice-path step-up gate (Vikunja #317) ---
|
||||
//
|
||||
// POST /api/ptt and GET /ws proxy audio into mavend's voice port, which runs
|
||||
// the same router, LLM and act path as POST /api/chat. They used to be
|
||||
// ungated on the grounds that the voice port is only reachable inside the
|
||||
// deploy, but mavweb is the thing proxying into it from outside. Speaking
|
||||
// "выключи свет" is not a smaller act than typing it.
|
||||
|
||||
// unreachableVoice is a closed port: a request that clears the gate fails at
|
||||
// the dial with 503, which is how these tests tell "passed" from "denied".
|
||||
const unreachableVoice = "127.0.0.1:1"
|
||||
|
||||
func pttReq() *http.Request {
|
||||
return httptest.NewRequest(http.MethodPost, "/api/ptt", strings.NewReader("PCM-ish bytes"))
|
||||
}
|
||||
|
||||
func TestHandlePTT_RequireStepUp_FailsClosed(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handlePTT(rr, pttReq(), unreachableVoice, nil, true)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePTT_UnassertedSession_Denied(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handlePTT(rr, pttReq(), unreachableVoice, webauthn.NewPasskeySession(5*time.Minute), false)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlePTT_AssertedSession_PassesGate(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handlePTT(rr, pttReq(), unreachableVoice, stepUpSession(), true)
|
||||
if rr.Code == http.StatusForbidden {
|
||||
t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String())
|
||||
}
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503 from the dial past the gate; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Default deploy: WebAuthn unconfigured and -require-stepup off ⇒ push-to-talk
|
||||
// keeps working, resting on the transport-level auth in front of mavweb.
|
||||
func TestHandlePTT_FailOpenByDefault(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handlePTT(rr, pttReq(), unreachableVoice, nil, false)
|
||||
if rr.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503 from the dial past the gate; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWS_RequireStepUp_FailsClosed(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, nil, true)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleWS_UnassertedSession_Denied(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, webauthn.NewPasskeySession(5*time.Minute), false)
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Past the gate the handshake itself fails (httptest's recorder cannot be
|
||||
// hijacked), which is not a 403. That is all this asserts: the gate let it by.
|
||||
func TestHandleWS_AssertedSession_PassesGate(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
handleWS(rr, httptest.NewRequest(http.MethodGet, "/ws", nil), unreachableVoice, stepUpSession(), true)
|
||||
if rr.Code == http.StatusForbidden {
|
||||
t.Fatalf("status = 403 on an asserted session; body=%s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
+186
-40
@@ -18,6 +18,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -71,6 +72,9 @@ var ecosystemHTML string
|
||||
//go:embed morning.html
|
||||
var morningHTML string
|
||||
|
||||
//go:embed events.html
|
||||
var eventsHTML string
|
||||
|
||||
// ── Ethos Workstation Shell ──
|
||||
//
|
||||
// Two template pieces that wrap every page:
|
||||
@@ -105,6 +109,7 @@ var sidebarSections = []struct {
|
||||
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
|
||||
{Label: "Routines", URL: "/routines", Key: "routines"},
|
||||
{Label: "Morning", URL: "/morning", Key: "morning"},
|
||||
{Label: "Intake", URL: "/events", Key: "events"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -314,6 +319,10 @@ var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shel
|
||||
// human surface is here (they ship no web UI of their own).
|
||||
var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML))
|
||||
|
||||
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
|
||||
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
|
||||
var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellTopHTML + eventsHTML + shellBottomHTML))
|
||||
|
||||
// morningTmpl — read-only view of today's checklist state per configured
|
||||
// morning routine (internal/morning). Same shape as trace.html: a plain
|
||||
// server-rendered page, refreshed on reload — no live-update loop, since
|
||||
@@ -343,7 +352,7 @@ func main() {
|
||||
coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)")
|
||||
pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)")
|
||||
pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)")
|
||||
requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (POST /tools, /routines, /api/revert, /api/chat) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour")
|
||||
requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (POST /tools, /routines, /models, /api/revert, /api/chat, /api/ptt and GET /ws) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour")
|
||||
pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)")
|
||||
nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)")
|
||||
praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)")
|
||||
@@ -355,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 {
|
||||
@@ -362,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()
|
||||
@@ -379,12 +399,9 @@ func main() {
|
||||
handleVoice(w, r)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleWS(w, r, *voiceAddr)
|
||||
})
|
||||
mux.HandleFunc("/api/ptt", func(w http.ResponseWriter, r *http.Request) {
|
||||
handlePTT(w, r, *voiceAddr)
|
||||
})
|
||||
// /ws and /api/ptt are registered further down, next to /api/chat: they
|
||||
// carry the same step-up gate and so need stepUpSession, which is only
|
||||
// built once the passkey endpoints are wired.
|
||||
mux.HandleFunc("/api/ping", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("pong"))
|
||||
})
|
||||
@@ -430,9 +447,12 @@ func main() {
|
||||
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleMorning(w, r, core)
|
||||
})
|
||||
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleEvents(w, r, core)
|
||||
})
|
||||
ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL}
|
||||
mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleEcosystem(w, r, ecoURLsCfg)
|
||||
handleEcosystem(w, r, ecoURLsCfg, core)
|
||||
})
|
||||
// ----- passkey (WebAuthn) endpoints -----
|
||||
// Wired when both -core and a configured origin are present. The origin
|
||||
@@ -464,10 +484,29 @@ func main() {
|
||||
mux.HandleFunc("/auth/webauthn/assert/finish", pk.AssertFinish)
|
||||
}
|
||||
if stepUpSession == nil {
|
||||
// One surface per line: these are read in a terminal at the moment
|
||||
// someone is deciding whether the box is safe to expose.
|
||||
surfaces := []string{
|
||||
"POST /tools defines arbitrary argv via name+cmd, which internal/tool then EXECUTES",
|
||||
"POST /routines accepting schedules recurring firing",
|
||||
"POST /models chooses the resident model that routes and words every turn",
|
||||
"POST /api/revert voids the latest fact for a key",
|
||||
"POST /api/chat reaches the router, the LLM and, through applyAction, the act path",
|
||||
"POST /api/ptt the same, from audio",
|
||||
"GET /ws the same, streamed",
|
||||
}
|
||||
if *requireStepUp {
|
||||
log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set: POST /tools (tool enable/disable/dismiss — defines and executes arbitrary argv), POST /routines (accepting schedules recurring firing), POST /api/revert and POST /api/chat (reaches the router, the LLM and the act path) will be DENIED (403). Set -webauthn-origin and -webauthn-rpid to enable passkey step-up.")
|
||||
log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set. These surfaces will be DENIED (403):")
|
||||
} else {
|
||||
log.Printf("SECURITY WARNING: step-up verification is DISABLED because -webauthn-origin/-webauthn-rpid are unset. UNGUARDED SURFACES: POST /tools (defines arbitrary argv via name+cmd, which internal/tool then EXECUTES), POST /routines (accepting schedules recurring firing), POST /api/revert (voids the latest fact for a key) and POST /api/chat (reaches the router, the LLM and, through applyAction, the act path). These are protected only by whatever transport-level auth sits in front of mavweb (wg+nginx+auth) — do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.")
|
||||
log.Printf("SECURITY WARNING: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset). These surfaces are UNGUARDED:")
|
||||
}
|
||||
for _, s := range surfaces {
|
||||
log.Printf("SECURITY: %s", s)
|
||||
}
|
||||
if *requireStepUp {
|
||||
log.Printf("SECURITY: set -webauthn-origin and -webauthn-rpid to enable passkey step-up.")
|
||||
} else {
|
||||
log.Printf("SECURITY: they rest on the transport-level auth in front of mavweb (wg+nginx+auth). Do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,22 +527,36 @@ 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,
|
||||
// LLM and act path as /api/chat
|
||||
// GET /ws step-up — same, streamed
|
||||
// POST /api/signal none — appends a presence fact, no argv, no act
|
||||
// POST /api/ptt, /ws none — proxy audio to mavend's voice port, which
|
||||
// is itself only reachable inside the deploy
|
||||
// POST /api/ambient shared secret — notification relay, constant-time
|
||||
// token compare, poster is a phone service
|
||||
// and not a browser, so step-up cannot apply
|
||||
//
|
||||
// "step-up" means stepUpOK: asserted passkey when WebAuthn is configured,
|
||||
// otherwise fail-open unless -require-stepup, which denies.
|
||||
//
|
||||
// /api/ptt and /ws used to be ungated, justified by mavend's voice port
|
||||
// being reachable only inside the deploy. That argument does not hold:
|
||||
// mavweb is the thing proxying into it from outside. Speaking "выключи
|
||||
// свет" is not a smaller act than typing it (Vikunja #317).
|
||||
//
|
||||
// The gate here is per-request, which costs the hands-free case a passkey
|
||||
// assertion per turn whenever WebAuthn is configured. A session-scoped
|
||||
// assertion covering a run of turns is the right shape and is its own task.
|
||||
//
|
||||
// GET /chat only renders the page and echoes back the q/r query params the
|
||||
// POST redirect set — nothing to gate.
|
||||
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -515,6 +568,12 @@ func main() {
|
||||
mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleRevert(w, r, core, stepUpSession, *requireStepUp)
|
||||
})
|
||||
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleWS(w, r, *voiceAddr, stepUpSession, *requireStepUp)
|
||||
})
|
||||
mux.HandleFunc("/api/ptt", func(w http.ResponseWriter, r *http.Request) {
|
||||
handlePTT(w, r, *voiceAddr, stepUpSession, *requireStepUp)
|
||||
})
|
||||
|
||||
srv := &http.Server{Addr: *addr, Handler: mux}
|
||||
|
||||
@@ -532,7 +591,11 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string) {
|
||||
func handleWS(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if !stepUpOK(session, requireStepUp) {
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
OriginPatterns: []string{"*"},
|
||||
})
|
||||
@@ -692,7 +755,7 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
||||
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||
<section class=card>
|
||||
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
|
||||
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p>
|
||||
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an <code>mcp:</code> scope came from an MCP server and already knows what it calls — check the command, then enable.</p>
|
||||
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
|
||||
{{range .Proposed}}<tr>
|
||||
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
|
||||
@@ -700,8 +763,8 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
||||
<input type=hidden name=name value="{{.Name}}">
|
||||
<input type=hidden name=scope value="{{.Scope}}">
|
||||
<input type=hidden name=action value=enable>
|
||||
<input type=text name=cmd class=input-wide placeholder="systemctl restart" required>
|
||||
<label><input type=checkbox name=destructive> destructive</label>
|
||||
<input type=text name=cmd class=input-wide placeholder="systemctl restart" value="{{join .Cmd " "}}" required>
|
||||
<label><input type=checkbox name=destructive {{if .Destructive}}checked{{end}}> destructive</label>
|
||||
<button class=btn>enable</button></form>
|
||||
<form method=post action=/tools class=inline-form>
|
||||
<input type=hidden name=name value="{{.Name}}">
|
||||
@@ -730,6 +793,19 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
||||
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
|
||||
</div>{{end}}
|
||||
</section>
|
||||
<section class=card>
|
||||
<h2 class=card-title>MCP servers <span class=badge>{{len .MCP}}</span></h2>
|
||||
{{if .MCP}}<p class=hint>servers she connects OUT to. Their tools appear above as proposals — a configured server is a place she may look, not a capability she has. A <code>stdio</code> target is a process on this box; an <code>http</code> one on a loopback or LAN address is inside the network, so treat its tools accordingly.</p>
|
||||
<div class=scroll><table><tr><th>name</th><th>transport</th><th>target</th><th>state</th><th>tools</th></tr>
|
||||
{{range .MCP}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Transport}}</span></td><td><code>{{.Target}}</code></td>
|
||||
<td>{{if .Connected}}connected{{if .Server}} — {{.Server}}{{end}}{{else}}<span class=red>down</span>{{if .Err}} — {{.Err}}{{end}}{{end}}</td>
|
||||
<td>{{.Tools}}</td></tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
||||
<div>no MCP servers configured</div>
|
||||
<div class=hint>add an <code>mcp.servers</code> block to mavend.json to let her use an external tool server</div>
|
||||
</div>{{end}}
|
||||
</section>
|
||||
{{template "shellBottom"}}`
|
||||
|
||||
// routinesHTML — proposed routine review surface. One row per thing maven
|
||||
@@ -841,17 +917,29 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
}
|
||||
}
|
||||
|
||||
// now — the wall clock, indirected so the task page can be rendered at a fixed
|
||||
// instant in a test. internal/tasks is pure and the daemon path already ranks
|
||||
// through a clock it is handed; the page had no reason to be the one surface
|
||||
// that could only be tested at whatever time it happened to run.
|
||||
var now = time.Now
|
||||
|
||||
// resolvedShown — how many finished tasks the page renders. The list is
|
||||
// history, it only grows, and the rows below the first screen are read by
|
||||
// nobody.
|
||||
const resolvedShown = 50
|
||||
|
||||
// taskRow is one line on /tasks, with every timestamp already formatted so the
|
||||
// template holds no date logic.
|
||||
type taskRow struct {
|
||||
ID int64
|
||||
Text string
|
||||
Source string
|
||||
Evidence string
|
||||
Status string
|
||||
Due string
|
||||
Created string
|
||||
Resolved string
|
||||
ID int64
|
||||
Text string
|
||||
Source string
|
||||
Evidence string
|
||||
Status string
|
||||
Due string
|
||||
Created string
|
||||
Resolved string
|
||||
ResolvedBy string
|
||||
// Why — the ranker's reason for this row's position (Vikunja #129), in
|
||||
// Russian, empty when nothing distinguished the task. Blank is the honest
|
||||
// rendering: he never said this one mattered more.
|
||||
@@ -899,6 +987,7 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
// rows keep store order (newest first) — ranking finished work is pointless.
|
||||
var live []tasks.Item
|
||||
var resolved []taskRow
|
||||
resolvedTotal := 0
|
||||
for _, t := range all {
|
||||
switch t.Status {
|
||||
case "candidate", "open":
|
||||
@@ -907,10 +996,18 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
|
||||
})
|
||||
default:
|
||||
resolvedTotal++
|
||||
// Finished work is history, and the history only grows. The page
|
||||
// showed every row that ever existed, which is a page that gets
|
||||
// slower every month for a section nobody reads past the top of.
|
||||
if len(resolved) >= resolvedShown {
|
||||
continue
|
||||
}
|
||||
resolved = append(resolved, taskRow{
|
||||
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
||||
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
||||
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
||||
ResolvedBy: t.ResolvedBy,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -919,7 +1016,7 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
byID[t.ID] = t
|
||||
}
|
||||
var cands, open []taskRow
|
||||
for _, r := range tasks.Rank(live, time.Now()) {
|
||||
for _, r := range tasks.Rank(live, now()) {
|
||||
t := byID[r.ID]
|
||||
row := taskRow{
|
||||
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
||||
@@ -938,11 +1035,12 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tasksTmpl.Execute(w, struct {
|
||||
Msg, Err string
|
||||
Candidates []taskRow
|
||||
Open []taskRow
|
||||
Resolved []taskRow
|
||||
}{msg, errMsg, cands, open, resolved}); err != nil {
|
||||
Msg, Err string
|
||||
Candidates []taskRow
|
||||
Open []taskRow
|
||||
Resolved []taskRow
|
||||
ResolvedMore bool
|
||||
}{msg, errMsg, cands, open, resolved, resolvedTotal > len(resolved)}); err != nil {
|
||||
log.Printf("tasks render: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -957,12 +1055,14 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
if text == "" {
|
||||
return "", errors.New("empty task text")
|
||||
}
|
||||
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: time.Now()}
|
||||
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()}
|
||||
// Importance is his, stated on the form. Out-of-range values are
|
||||
// clamped rather than rejected — a bad select is not worth a 400.
|
||||
if v := r.FormValue("weight"); v != "" {
|
||||
var wgt int
|
||||
if n, _ := fmt.Sscanf(v, "%d", &wgt); n != 1 || wgt < 0 {
|
||||
// strconv, not Sscanf: Sscanf("3junk", "%d") succeeds with 3, and a
|
||||
// form value is not a place to accept trailing garbage.
|
||||
wgt, err := strconv.Atoi(v)
|
||||
if err != nil || wgt < 0 {
|
||||
return "", fmt.Errorf("bad weight %q", v)
|
||||
}
|
||||
if wgt > tasks.MaxWeight {
|
||||
@@ -971,7 +1071,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
req.Weight = wgt
|
||||
}
|
||||
if d := r.FormValue("due"); d != "" {
|
||||
due, err := time.ParseInLocation("2006-01-02", d, time.Local)
|
||||
due, err := time.ParseInLocation("2006-01-02", d, now().Location())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("bad due date %q", d)
|
||||
}
|
||||
@@ -981,14 +1081,17 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.Promoted {
|
||||
return "confirmed a candidate maven had found", nil
|
||||
}
|
||||
if !resp.Created {
|
||||
return "already on the list", nil
|
||||
}
|
||||
return "added task", nil
|
||||
}
|
||||
|
||||
var id int64
|
||||
if n, _ := fmt.Sscanf(r.FormValue("id"), "%d", &id); n != 1 {
|
||||
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid id")
|
||||
}
|
||||
var status, msg string
|
||||
@@ -1002,7 +1105,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
default:
|
||||
return "", fmt.Errorf("unknown action %q", action)
|
||||
}
|
||||
if err := core.SetTaskStatus(ctx, id, status, time.Now()); err != nil {
|
||||
if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return msg, nil
|
||||
@@ -1193,6 +1296,37 @@ type morningView struct {
|
||||
Routines []ipc.MorningRoutineStatus
|
||||
}
|
||||
|
||||
// eventsView — what /events renders. Err is set instead of Events when the
|
||||
// core could not serve the journal, so the page says why rather than showing an
|
||||
// empty intake and implying nothing arrived.
|
||||
type eventsView struct {
|
||||
Events []ipc.IntakeEvent
|
||||
Err string
|
||||
}
|
||||
|
||||
// eventsPageLimit — how many envelopes the page shows. The ring holds more; a
|
||||
// page is for scanning what just happened, not for archaeology.
|
||||
const eventsPageLimit = 200
|
||||
|
||||
func handleEvents(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if core == nil {
|
||||
http.Error(w, "intake journal disabled (no -core)", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var view eventsView
|
||||
evs, err := core.RecentEvents(r.Context(), eventsPageLimit)
|
||||
if err != nil {
|
||||
log.Printf("events: %v", err)
|
||||
view.Err = err.Error()
|
||||
} else {
|
||||
view.Events = evs
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := eventsTmpl.Execute(w, view); err != nil {
|
||||
log.Printf("events render: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := voiceTmpl.Execute(w, nil); err != nil {
|
||||
@@ -1320,12 +1454,20 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sessi
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
// MCP is off by default and an older core may not know the method at all,
|
||||
// so a failure here renders an empty section rather than breaking the page.
|
||||
servers, err := core.MCPServers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("tools: mcp servers: %v", err)
|
||||
servers = nil
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := toolsTmpl.Execute(w, struct {
|
||||
Msg string
|
||||
Proposed []ipc.Tool
|
||||
Enabled []ipc.Tool
|
||||
}{msg, proposed, enabled}); err != nil {
|
||||
MCP []ipc.MCPServerStatus
|
||||
}{msg, proposed, enabled, servers}); err != nil {
|
||||
log.Printf("tools render: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1386,11 +1528,15 @@ func readOneFrame(r io.Reader) (*voice.Response, *voice.Push, error) {
|
||||
return &voice.Response{ID: raw.ID, Result: raw.Result, Error: raw.Error}, nil, nil
|
||||
}
|
||||
|
||||
func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string) {
|
||||
func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", 405)
|
||||
return
|
||||
}
|
||||
if !stepUpOK(session, requireStepUp) {
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
|
||||
+23
-7
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
const prfTestOrigin = "https://maven.test"
|
||||
const prfTestRPID = "maven.test"
|
||||
|
||||
// fakeKeyIPC stands in for the mavend socket and records exactly what secret
|
||||
// each call received — the point of the whole test file is that it is the PRF
|
||||
// output and never the credential public key.
|
||||
type fakeKeyIPC struct {
|
||||
unlockSecret []byte
|
||||
wrapSecret []byte
|
||||
unlockCalls int
|
||||
wrapCalls int
|
||||
unlockErr error
|
||||
wrapExplicit bool
|
||||
// opensWith, when set, is the only secret Unlock accepts. It stands in
|
||||
// for a wrapped blob on disk: everything else gets unlockErr.
|
||||
opensWith []byte
|
||||
}
|
||||
|
||||
func (f *fakeKeyIPC) Unlock(_ context.Context, secret []byte) error {
|
||||
f.unlockCalls++
|
||||
f.unlockSecret = bytes.Clone(secret)
|
||||
if f.opensWith != nil {
|
||||
if bytes.Equal(secret, f.opensWith) {
|
||||
return nil
|
||||
}
|
||||
return errors.New("unwrap key: decrypt failed (wrong credential?)")
|
||||
}
|
||||
return f.unlockErr
|
||||
}
|
||||
|
||||
func (f *fakeKeyIPC) StoreEncryptionKey(_ context.Context, secret []byte, explicit bool) error {
|
||||
f.wrapCalls++
|
||||
f.wrapSecret = bytes.Clone(secret)
|
||||
f.wrapExplicit = explicit
|
||||
return nil
|
||||
}
|
||||
|
||||
func b64u(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
// prfAuthenticator is a minimal software authenticator: a P-256 key plus the
|
||||
// COSE encoding of its public half.
|
||||
type prfAuthenticator struct {
|
||||
key *ecdsa.PrivateKey
|
||||
credID []byte
|
||||
cose []byte
|
||||
}
|
||||
|
||||
func newPRFAuthenticator(t *testing.T) *prfAuthenticator {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
x := key.PublicKey.X.FillBytes(make([]byte, 32))
|
||||
y := key.PublicKey.Y.FillBytes(make([]byte, 32))
|
||||
// COSE_Key: {1: 2 (EC2), 3: -7 (ES256), -1: 1 (P-256), -2: x, -3: y}
|
||||
var c []byte
|
||||
c = append(c, 0xa5) // map(5)
|
||||
c = append(c, 0x01, 0x02) // 1: 2
|
||||
c = append(c, 0x03, 0x26) // 3: -7
|
||||
c = append(c, 0x20, 0x01) // -1: 1
|
||||
c = append(c, 0x21, 0x58, 0x20) // -2: bytes(32)
|
||||
c = append(c, x...)
|
||||
c = append(c, 0x22, 0x58, 0x20) // -3: bytes(32)
|
||||
c = append(c, y...)
|
||||
return &prfAuthenticator{key: key, credID: []byte("prf-cred"), cose: c}
|
||||
}
|
||||
|
||||
func (a *prfAuthenticator) authData(flags byte, counter uint32, attested bool) []byte {
|
||||
h := sha256.Sum256([]byte(prfTestRPID))
|
||||
d := append([]byte{}, h[:]...)
|
||||
d = append(d, flags)
|
||||
cb := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(cb, counter)
|
||||
d = append(d, cb...)
|
||||
if attested {
|
||||
d = append(d, make([]byte, 16)...) // aaguid
|
||||
l := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(l, uint16(len(a.credID)))
|
||||
d = append(d, l...)
|
||||
d = append(d, a.credID...)
|
||||
d = append(d, a.cose...)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func clientDataJSON(typ, challenge string) []byte {
|
||||
b, _ := json.Marshal(map[string]string{"type": typ, "challenge": challenge, "origin": prfTestOrigin})
|
||||
return b
|
||||
}
|
||||
|
||||
// register drives POST /register/finish with a valid attestation.
|
||||
func (a *prfAuthenticator) register(t *testing.T, h *PasskeyHandle) {
|
||||
t.Helper()
|
||||
_, chal, err := h.rp.CreationOptions([]byte("u"), "user")
|
||||
if err != nil {
|
||||
t.Fatalf("CreationOptions: %v", err)
|
||||
}
|
||||
// {"fmt":"none","attStmt":{},"authData":<bytes>}
|
||||
att := []byte{0xa3}
|
||||
att = append(att, 0x63, 'f', 'm', 't', 0x64, 'n', 'o', 'n', 'e')
|
||||
att = append(att, 0x67, 'a', 't', 't', 'S', 't', 'm', 't', 0xa0)
|
||||
ad := a.authData(1<<6|0x05, 0, true)
|
||||
att = append(att, 0x68, 'a', 'u', 't', 'h', 'D', 'a', 't', 'a')
|
||||
att = append(att, 0x59, byte(len(ad)>>8), byte(len(ad)))
|
||||
att = append(att, ad...)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"challenge": chal,
|
||||
"credential": map[string]any{
|
||||
"id": b64u(a.credID),
|
||||
"type": "public-key",
|
||||
"response": map[string]any{
|
||||
"clientDataJSON": b64u(clientDataJSON("webauthn.create", chal)),
|
||||
"attestationObject": b64u(att),
|
||||
},
|
||||
},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
h.RegisterFinish(w, httptest.NewRequest(http.MethodPost, "/auth/webauthn/register/finish", bytes.NewReader(body)))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("RegisterFinish: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// assert drives POST /assert/finish with a valid assertion and the given
|
||||
// base64url PRF result.
|
||||
func (a *prfAuthenticator) assert(t *testing.T, h *PasskeyHandle, prf string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return a.assertExplicit(t, h, prf, false)
|
||||
}
|
||||
|
||||
// assertExplicit is assert with control over the explicit flag the rewrite
|
||||
// button sets.
|
||||
func (a *prfAuthenticator) assertExplicit(t *testing.T, h *PasskeyHandle, prf string, explicit bool) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
_, chal, err := h.rp.AssertionOptions()
|
||||
if err != nil {
|
||||
t.Fatalf("AssertionOptions: %v", err)
|
||||
}
|
||||
ad := a.authData(0x05, 7, false)
|
||||
cdj := clientDataJSON("webauthn.get", chal)
|
||||
hash := sha256.Sum256(cdj)
|
||||
sig, err := ecdsa.SignASN1(rand.Reader, a.key, append(append([]byte{}, ad...), hash[:]...))
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"challenge": chal,
|
||||
"prf": prf,
|
||||
"explicit": explicit,
|
||||
"credential": map[string]any{
|
||||
"id": b64u(a.credID),
|
||||
"type": "public-key",
|
||||
"response": map[string]any{
|
||||
"clientDataJSON": b64u(cdj),
|
||||
"authenticatorData": b64u(ad),
|
||||
"signature": b64u(sig),
|
||||
},
|
||||
},
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
h.AssertFinish(w, httptest.NewRequest(http.MethodPost, "/auth/webauthn/assert/finish", bytes.NewReader(body)))
|
||||
return w
|
||||
}
|
||||
|
||||
func newPRFHandle(t *testing.T, key *fakeKeyIPC) *PasskeyHandle {
|
||||
t.Helper()
|
||||
store, err := newCredentialStore(filepath.Join(t.TempDir(), "passkeys.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("credential store: %v", err)
|
||||
}
|
||||
return &PasskeyHandle{
|
||||
rp: webauthn.NewRP(webauthn.Config{Origin: prfTestOrigin, RPID: prfTestRPID, RPName: "maven"}),
|
||||
encryptFn: key,
|
||||
store: store,
|
||||
session: webauthn.NewPasskeySession(0),
|
||||
}
|
||||
}
|
||||
|
||||
// The fix for Vikunja #14: what goes over IPC is the PRF secret from the
|
||||
// authenticator, not the credential public key sitting in passkeys.json.
|
||||
func TestAssertSendsPRFSecretNotPublicKey(t *testing.T) {
|
||||
key := &fakeKeyIPC{}
|
||||
h := newPRFHandle(t, key)
|
||||
auth := newPRFAuthenticator(t)
|
||||
auth.register(t, h)
|
||||
|
||||
// Enrolment must not wrap anything: create() yields no PRF result.
|
||||
if key.wrapCalls != 0 || key.unlockCalls != 0 {
|
||||
t.Fatalf("registration touched the key IPC (wrap=%d unlock=%d)", key.wrapCalls, key.unlockCalls)
|
||||
}
|
||||
|
||||
secret := make([]byte, 32)
|
||||
for i := range secret {
|
||||
secret[i] = byte(i + 1)
|
||||
}
|
||||
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
|
||||
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
if key.unlockCalls != 1 || key.wrapCalls != 1 {
|
||||
t.Fatalf("unlock=%d wrap=%d, want 1 and 1", key.unlockCalls, key.wrapCalls)
|
||||
}
|
||||
if !bytes.Equal(key.unlockSecret, secret) {
|
||||
t.Errorf("Unlock got %x, want the PRF secret %x", key.unlockSecret, secret)
|
||||
}
|
||||
if !bytes.Equal(key.wrapSecret, secret) {
|
||||
t.Errorf("StoreEncryptionKey got %x, want the PRF secret %x", key.wrapSecret, secret)
|
||||
}
|
||||
// And explicitly: not the credential public key.
|
||||
pub, _, err := h.store.Lookup(b64u(auth.credID))
|
||||
if err != nil {
|
||||
t.Fatalf("lookup: %v", err)
|
||||
}
|
||||
if bytes.Equal(key.unlockSecret, pub) {
|
||||
t.Fatal("the credential public key was sent as the unlock secret")
|
||||
}
|
||||
}
|
||||
|
||||
// An authenticator without PRF must produce no unlock attempt at all — the
|
||||
// assertion still succeeds (step-up works), but cold-start unlock stays off
|
||||
// rather than falling back to something weaker.
|
||||
func TestAssertWithoutPRFDoesNotUnlock(t *testing.T) {
|
||||
for _, prf := range []string{"", "!!!not-base64!!!", b64u(make([]byte, 32)), b64u(make([]byte, 16))} {
|
||||
key := &fakeKeyIPC{}
|
||||
h := newPRFHandle(t, key)
|
||||
auth := newPRFAuthenticator(t)
|
||||
auth.register(t, h)
|
||||
|
||||
w := auth.assert(t, h, prf)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("prf=%q: AssertFinish %d %s", prf, w.Code, w.Body.String())
|
||||
}
|
||||
if key.unlockCalls != 0 || key.wrapCalls != 0 {
|
||||
t.Errorf("prf=%q: unlock=%d wrap=%d, want no key IPC at all", prf, key.unlockCalls, key.wrapCalls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A failed unlock must not fail the assertion: step-up is independently valid,
|
||||
// and a locked daemon degrades rather than breaking the login.
|
||||
func TestAssertSucceedsWhenUnlockFails(t *testing.T) {
|
||||
key := &fakeKeyIPC{unlockErr: errors.New("wrong credential")}
|
||||
h := newPRFHandle(t, key)
|
||||
auth := newPRFAuthenticator(t)
|
||||
auth.register(t, h)
|
||||
|
||||
secret := bytes.Repeat([]byte{3}, 32)
|
||||
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
|
||||
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if key.unlockCalls == 0 {
|
||||
t.Error("unlock was never attempted")
|
||||
}
|
||||
}
|
||||
|
||||
// A forged assertion must never reach the unlock path.
|
||||
func TestForgedAssertionNeverUnlocks(t *testing.T) {
|
||||
key := &fakeKeyIPC{}
|
||||
h := newPRFHandle(t, key)
|
||||
auth := newPRFAuthenticator(t)
|
||||
auth.register(t, h)
|
||||
|
||||
// A different key signing over the same credential id.
|
||||
attacker := newPRFAuthenticator(t)
|
||||
attacker.credID = auth.credID
|
||||
w := attacker.assert(t, h, b64u(bytes.Repeat([]byte{4}, 32)))
|
||||
if w.Code == http.StatusOK {
|
||||
t.Fatal("an assertion signed by the wrong key was accepted")
|
||||
}
|
||||
if key.unlockCalls != 0 || key.wrapCalls != 0 {
|
||||
t.Fatalf("a forged assertion reached the key IPC (unlock=%d wrap=%d)", key.unlockCalls, key.wrapCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// The browser side is the only place the PRF result exists. If the page stops
|
||||
// asking for it or stops reading it back, cold-start unlock silently dies with
|
||||
// nothing failing, so the page source is asserted directly.
|
||||
func TestPasskeyPageRequestsAndPostsPRF(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"getClientExtensionResults",
|
||||
"ext.prf.results.first",
|
||||
"body:JSON.stringify({challenge,prf,",
|
||||
} {
|
||||
if !strings.Contains(passkeyPageHTML, want) {
|
||||
t.Errorf("the passkey page no longer contains %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A box enrolled before Vikunja #14 has a v1 blob wrapped under the credential
|
||||
// PUBLIC key. The PRF secret cannot open it, and this handler is the only
|
||||
// caller of Unlock, so without the legacy retry that box stays locked forever
|
||||
// while a perfectly good passkey is asserted at it.
|
||||
func TestLegacyV1BlobStillColdStarts(t *testing.T) {
|
||||
key := &fakeKeyIPC{}
|
||||
h := newPRFHandle(t, key)
|
||||
auth := newPRFAuthenticator(t)
|
||||
auth.register(t, h)
|
||||
|
||||
pub, _, err := h.store.Lookup(b64u(auth.credID))
|
||||
if err != nil {
|
||||
t.Fatalf("lookup: %v", err)
|
||||
}
|
||||
// The daemon only opens under the public key — a v1 blob.
|
||||
key.opensWith = pub
|
||||
|
||||
secret := bytes.Repeat([]byte{9}, 32)
|
||||
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
|
||||
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if key.unlockCalls != 2 {
|
||||
t.Fatalf("unlock attempted %d times, want 2 (PRF, then the legacy public key)", key.unlockCalls)
|
||||
}
|
||||
if !bytes.Equal(key.unlockSecret, pub) {
|
||||
t.Fatal("the legacy retry did not send the credential public key, so a v1 box can never cold-start again")
|
||||
}
|
||||
}
|
||||
|
||||
// The PRF secret is tried first and, when it works, the public key is never
|
||||
// sent. The legacy retry is a one-way door out of v1, not a fallback offered
|
||||
// to every assertion.
|
||||
func TestPRFUnlockNeverFallsBackWhenItWorks(t *testing.T) {
|
||||
secret := bytes.Repeat([]byte{7}, 32)
|
||||
key := &fakeKeyIPC{opensWith: secret}
|
||||
h := newPRFHandle(t, key)
|
||||
auth := newPRFAuthenticator(t)
|
||||
auth.register(t, h)
|
||||
|
||||
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
|
||||
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if key.unlockCalls != 1 {
|
||||
t.Fatalf("unlock attempted %d times, want 1", key.unlockCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// Wrapping the at-rest key is an explicit act, never a side effect of a
|
||||
// step-up. A page POSTing a substituted prf on a routine assertion must not
|
||||
// make the daemon re-wrap the database key under it.
|
||||
func TestPlainAssertionAsksForNoRewrite(t *testing.T) {
|
||||
key := &fakeKeyIPC{}
|
||||
h := newPRFHandle(t, key)
|
||||
auth := newPRFAuthenticator(t)
|
||||
auth.register(t, h)
|
||||
|
||||
if w := auth.assert(t, h, b64u(bytes.Repeat([]byte{5}, 32))); w.Code != http.StatusOK {
|
||||
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if key.wrapCalls != 1 {
|
||||
t.Fatalf("wrapCalls = %d, want 1", key.wrapCalls)
|
||||
}
|
||||
if key.wrapExplicit {
|
||||
t.Fatal("a plain step-up asked the daemon to rewrite the cold-start key")
|
||||
}
|
||||
}
|
||||
|
||||
// The rewrite button, and only the rewrite button, sets explicit.
|
||||
func TestRewriteButtonAsksForAnExplicitWrap(t *testing.T) {
|
||||
key := &fakeKeyIPC{}
|
||||
h := newPRFHandle(t, key)
|
||||
auth := newPRFAuthenticator(t)
|
||||
auth.register(t, h)
|
||||
|
||||
if w := auth.assertExplicit(t, h, b64u(bytes.Repeat([]byte{6}, 32)), true); w.Code != http.StatusOK {
|
||||
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !key.wrapExplicit {
|
||||
t.Fatal("the explicit flag did not reach the daemon, so the rewrite button cannot work")
|
||||
}
|
||||
}
|
||||
|
||||
// The page is the only place the explicit flag originates. If the button or
|
||||
// the field goes away, rewriting a cold-start key becomes impossible with
|
||||
// nothing failing.
|
||||
func TestPasskeyPageHasTheRewriteButton(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"rewrite cold-start key",
|
||||
"explicit:!!explicit",
|
||||
"async function rewrapKey()",
|
||||
} {
|
||||
if !strings.Contains(passkeyPageHTML, want) {
|
||||
t.Errorf("the passkey page no longer contains %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@
|
||||
<input type=hidden name=action value=add>
|
||||
<input type=text name=text placeholder="что нужно сделать" size=44 required>
|
||||
<input type=date name=due title="due date (optional)">
|
||||
<!-- weight 1 is skipped on purpose: the two rungs here are the two words she
|
||||
recognises out loud ("важно", "срочно"), so the form and the spoken markers
|
||||
mean the same thing. -->
|
||||
<select name=weight title="importance (optional)">
|
||||
<option value=0>normal</option>
|
||||
<option value=2>важно</option>
|
||||
@@ -43,7 +46,7 @@
|
||||
|
||||
<section class=card>
|
||||
<h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2>
|
||||
<div class=hint>most pressing first — by the deadlines and the urgency you gave, nothing guessed.</div>
|
||||
<div class=hint>most pressing first — by the deadlines and the urgency you gave. nothing about a task is guessed; the only signal that is not yours is age, which lifts anything sitting here for weeks.</div>
|
||||
{{if .Open}}<div class=scroll><table>
|
||||
<tr><th>task</th><th>why</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
||||
{{range .Open}}<tr>
|
||||
@@ -72,12 +75,14 @@
|
||||
<section class=card>
|
||||
<h2 class=card-title>resolved <span class=badge>{{len .Resolved}}</span></h2>
|
||||
<div class=scroll><table>
|
||||
<tr><th>task</th><th>status</th><th>when</th></tr>
|
||||
<tr><th>task</th><th>status</th><th>when</th><th>by</th></tr>
|
||||
{{range .Resolved}}<tr>
|
||||
<td class=text-max>{{.Text}}</td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td class=muted>{{.Resolved}}</td>
|
||||
<td class=hint>{{.ResolvedBy}}</td>
|
||||
</tr>{{end}}</table></div>
|
||||
{{if .ResolvedMore}}<div class=hint>only the {{len .Resolved}} most recent are shown.</div>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{template "shellBottom"}}
|
||||
|
||||
+119
-3
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -27,7 +28,10 @@ type fakeTaskCore struct {
|
||||
|
||||
statusID int64
|
||||
statusVal string
|
||||
statusBy string
|
||||
statusErr error
|
||||
|
||||
promoted bool
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||
@@ -42,11 +46,11 @@ func (f *fakeTaskCore) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (i
|
||||
if f.captureErr != nil {
|
||||
return ipc.CaptureTaskResp{}, f.captureErr
|
||||
}
|
||||
return ipc.CaptureTaskResp{ID: 7, Created: f.created}, nil
|
||||
return ipc.CaptureTaskResp{ID: 7, Created: f.created, Promoted: f.promoted}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time) error {
|
||||
f.statusID, f.statusVal = id, status
|
||||
func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error {
|
||||
f.statusID, f.statusVal, f.statusBy = id, status, by
|
||||
return f.statusErr
|
||||
}
|
||||
|
||||
@@ -223,3 +227,115 @@ func TestApplyTaskPostClampsWeight(t *testing.T) {
|
||||
t.Errorf("weight = %d, want the cap", core.captured[0].Weight)
|
||||
}
|
||||
}
|
||||
|
||||
// The page ranked with the wall clock while the daemon path ranked with a clock
|
||||
// it was handed, so this was the one surface that could only be tested at
|
||||
// whatever time it happened to run.
|
||||
func TestHandleTasksRanksAtTheInjectedClock(t *testing.T) {
|
||||
fixed := time.Date(2026, 8, 1, 10, 0, 0, 0, time.FixedZone("UTC+4", 4*3600))
|
||||
old := now
|
||||
now = func() time.Time { return fixed }
|
||||
t.Cleanup(func() { now = old })
|
||||
|
||||
// Due tomorrow, local time, stored the way the store hands it back: UTC.
|
||||
due := time.Date(2026, 8, 2, 0, 0, 0, 0, fixed.Location()).UTC()
|
||||
core := &fakeTaskCore{tasks: []ipc.Task{
|
||||
{ID: 1, Text: "оплатить интернет", Status: "open", CreatedTs: fixed, Due: &due},
|
||||
}}
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "завтра") {
|
||||
t.Errorf("why column does not say завтра: %q", why(body))
|
||||
}
|
||||
if strings.Contains(body, "сегодня") || strings.Contains(body, "просрочено") {
|
||||
t.Error("a task due tomorrow was ranked as today's or overdue")
|
||||
}
|
||||
}
|
||||
|
||||
// why is a crude excerpt of the rendered why column, for a readable failure.
|
||||
func why(body string) string {
|
||||
i := strings.Index(body, "<td class=hint>")
|
||||
if i < 0 {
|
||||
return body
|
||||
}
|
||||
j := i + 200
|
||||
if j > len(body) {
|
||||
j = len(body)
|
||||
}
|
||||
return body[i:j]
|
||||
}
|
||||
|
||||
// The resolved section rendered every row that ever existed.
|
||||
func TestHandleTasksBoundsResolved(t *testing.T) {
|
||||
base := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
var rows []ipc.Task
|
||||
for i := 0; i < resolvedShown+10; i++ {
|
||||
ts := base.Add(time.Duration(i) * time.Minute)
|
||||
rows = append(rows, ipc.Task{
|
||||
ID: int64(i + 1), Text: fmt.Sprintf("задача %d", i), Status: "done",
|
||||
CreatedTs: ts, Resolved: &ts,
|
||||
})
|
||||
}
|
||||
core := &fakeTaskCore{tasks: rows}
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||
body := rec.Body.String()
|
||||
if n := strings.Count(body, "задача "); n != resolvedShown {
|
||||
t.Errorf("rendered %d resolved rows, want the %d-row bound", n, resolvedShown)
|
||||
}
|
||||
if !strings.Contains(body, "most recent are shown") {
|
||||
t.Error("the page must say it is showing only part of the history")
|
||||
}
|
||||
}
|
||||
|
||||
// A capture over a candidate is a confirmation, not a duplicate.
|
||||
func TestHandleTasksAddSaysPromoted(t *testing.T) {
|
||||
core := &fakeTaskCore{promoted: true}
|
||||
form := url.Values{"action": {"add"}, "text": {"продлить страховку"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, req, core)
|
||||
if !strings.Contains(rec.Body.String(), "confirmed a candidate") {
|
||||
t.Error("a promoted capture must not read as a duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
// Sscanf accepted "3junk" as 3, and the same call parsed the row id.
|
||||
func TestApplyTaskPostRejectsTrailingGarbage(t *testing.T) {
|
||||
core := &fakeTaskCore{created: true}
|
||||
form := url.Values{"action": {"add"}, "text": {"что-то"}, "weight": {"3junk"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, req, core)
|
||||
if len(core.captured) != 0 {
|
||||
t.Errorf("captured %+v, want nothing on a malformed weight", core.captured)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "bad weight") {
|
||||
t.Error("error not surfaced on the page")
|
||||
}
|
||||
|
||||
core = &fakeTaskCore{}
|
||||
form = url.Values{"action": {"done"}, "id": {"42junk"}}
|
||||
req = httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleTasks(httptest.NewRecorder(), req, core)
|
||||
if core.statusID != 0 {
|
||||
t.Errorf("SetTaskStatus called with id %d on a malformed id", core.statusID)
|
||||
}
|
||||
}
|
||||
|
||||
// A resolution says what resolved it: resolved_ts recorded when and never by
|
||||
// what.
|
||||
func TestHandleTasksRecordsTheCaller(t *testing.T) {
|
||||
core := &fakeTaskCore{}
|
||||
form := url.Values{"action": {"done"}, "id": {"42"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleTasks(httptest.NewRecorder(), req, core)
|
||||
if core.statusBy != "tap:web" {
|
||||
t.Errorf("resolved by %q, want tap:web", core.statusBy)
|
||||
}
|
||||
}
|
||||
|
||||
+123
-38
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -23,8 +24,8 @@ type assertIPC interface {
|
||||
// is *ipc.Client; in-process CoreAPI adapters do not implement it. When nil,
|
||||
// StoreEncryptionKey and Unlock are silently skipped.
|
||||
type keyIPC interface {
|
||||
StoreEncryptionKey(ctx context.Context, publicKey []byte) error
|
||||
Unlock(ctx context.Context, publicKey []byte) error
|
||||
StoreEncryptionKey(ctx context.Context, secret []byte, explicit bool) error
|
||||
Unlock(ctx context.Context, secret []byte) error
|
||||
}
|
||||
|
||||
// PasskeyHandle holds the WebAuthn relying party, a local in-memory credential
|
||||
@@ -86,8 +87,10 @@ const passkeyPageHTML = `{{template "shellTop" "passkey"}}
|
||||
<div class=flex gap-2>
|
||||
<button class=btn onclick=enroll()>enroll passkey</button>
|
||||
<button class=btn onclick=assert()>assert (step-up)</button>
|
||||
<button class=btn onclick=rewrapKey()>rewrite cold-start key</button>
|
||||
<a href=/tools><button class=btn-primary>→ tools</button></a>
|
||||
</div>
|
||||
<p class=hint>Rewriting the cold-start key points it at the passkey you assert next. Every other enrolled passkey stops being able to unlock a cold-booted daemon.</p>
|
||||
<div id=msg></div>
|
||||
{{template "shellBottom"}}
|
||||
<script>
|
||||
@@ -102,18 +105,39 @@ async function enroll(){try{
|
||||
const r=await fetch('/auth/webauthn/register/finish',{method:'POST',headers:{'content-type':'application/json'},
|
||||
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
|
||||
clientDataJSON:b64u(c.response.clientDataJSON),attestationObject:b64u(c.response.attestationObject)}}})});
|
||||
say(r.ok?'enrolled ✓':'enroll failed: '+await r.text(),r.ok);
|
||||
if(!r.ok){say('enroll failed: '+await r.text(),false);return;}
|
||||
// The wrapped key can only be written from an assertion: PRF results are
|
||||
// not produced at create() time on most authenticators. Enrolment reports
|
||||
// whether PRF is available at all so he is not told cold-start works when
|
||||
// it cannot.
|
||||
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
|
||||
const prfOK=!!(ext.prf&&ext.prf.enabled);
|
||||
say(prfOK?'enrolled ✓ — now assert once to write the cold-start key':
|
||||
'enrolled ✓ — but this authenticator has no PRF: cold-start unlock unavailable',true);
|
||||
}catch(e){say('enroll error: '+e,false);}}
|
||||
async function assert(){try{
|
||||
async function assert(explicit){try{
|
||||
const {challenge,options}=await (await fetch('/auth/webauthn/assert/begin')).json();
|
||||
options.challenge=ub64(options.challenge);
|
||||
const c=await navigator.credentials.get({publicKey:options});
|
||||
// The PRF result is the cold-start secret. It never touches localStorage
|
||||
// and is posted once, over the same request as the assertion.
|
||||
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
|
||||
const prf=ext.prf&&ext.prf.results&&ext.prf.results.first?b64u(ext.prf.results.first):'';
|
||||
const r=await fetch('/auth/webauthn/assert/finish',{method:'POST',headers:{'content-type':'application/json'},
|
||||
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
|
||||
body:JSON.stringify({challenge,prf,explicit:!!explicit,credential:{id:c.id,type:c.type,response:{
|
||||
clientDataJSON:b64u(c.response.clientDataJSON),authenticatorData:b64u(c.response.authenticatorData),
|
||||
signature:b64u(c.response.signature)}}})});
|
||||
say(r.ok?'stepped up ✓ — enable tools now':'assert failed: '+await r.text(),r.ok);
|
||||
if(!r.ok){say('assert failed: '+await r.text(),false);return;}
|
||||
if(!prf){say('stepped up ✓ — no PRF from this authenticator, so cold-start unlock stayed unavailable',true);return;}
|
||||
say(explicit?'stepped up ✓ — cold-start key now points at this passkey':
|
||||
'stepped up ✓ — enable tools now',true);
|
||||
}catch(e){say('assert error: '+e,false);}}
|
||||
// Rewriting the wrapped key is a separate gesture, never a side effect of a
|
||||
// step-up. Only this button sets explicit, and only explicit lets the daemon
|
||||
// replace a blob that already exists.
|
||||
async function rewrapKey(){
|
||||
if(!confirm('Rewrite the cold-start key under the passkey you are about to assert? Every other enrolled passkey stops being able to unlock a cold-booted daemon.'))return;
|
||||
await assert(true);}
|
||||
</script>`
|
||||
|
||||
func (h *PasskeyHandle) RegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -140,9 +164,7 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var enrolledPublicKey []byte
|
||||
save := func(id string, publicKey []byte, _ []byte, _ string) error {
|
||||
enrolledPublicKey = publicKey
|
||||
return h.store.Save(id, publicKey)
|
||||
}
|
||||
credID, err := h.rp.FinishRegistration(save, body.Challenge, body.Credential)
|
||||
@@ -153,19 +175,15 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
log.Printf("webauthn: registered credential %s", credID)
|
||||
|
||||
// If mavend is reachable and supports key wrapping, store the encryption
|
||||
// key wrapped with this credential's public key — enables cold-start unlock.
|
||||
if h.encryptFn != nil && enrolledPublicKey != nil {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.encryptFn.StoreEncryptionKey(ctx, enrolledPublicKey); err != nil {
|
||||
log.Printf("webauthn: store encryption key: %v", err)
|
||||
// Non-fatal: enrollment still succeeded, the wrapped key can be
|
||||
// created later via the same endpoint.
|
||||
} else {
|
||||
log.Printf("webauthn: encryption key wrapped with credential %s", credID)
|
||||
}
|
||||
}
|
||||
// Note what does NOT happen here: the encryption key is not wrapped at
|
||||
// enrolment. Wrapping needs the authenticator's PRF output, and create()
|
||||
// does not produce one on most authenticators — it only reports whether
|
||||
// the extension is supported. The wrapped key is written on the first
|
||||
// assertion instead (see AssertFinish).
|
||||
//
|
||||
// This used to wrap the key under the credential *public* key, which is
|
||||
// written to passkeys.json next to the wrapped blob. See the header of
|
||||
// internal/webauthn/keywrap.go.
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]string{"credential_id": credID})
|
||||
}
|
||||
@@ -189,6 +207,24 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Credential map[string]any `json:"credential"`
|
||||
// PRF is the base64url WebAuthn PRF output the browser read out of
|
||||
// getClientExtensionResults(). Empty when the authenticator has no
|
||||
// PRF extension: cold-start unlock is then unavailable and we say so
|
||||
// rather than falling back to something weaker.
|
||||
//
|
||||
// Known property, accepted deliberately: this value is supplied by
|
||||
// the client and is NOT covered by the assertion signature. WebAuthn
|
||||
// client extension outputs never are, and binding one would need a
|
||||
// per-assertion salt, which would make the wrapped blob unopenable on
|
||||
// the next boot. Nothing here can tell a real PRF output from 32
|
||||
// bytes a compromised page chose. What limits the damage is that the
|
||||
// daemon refuses to rewrite an existing blob unless the operator
|
||||
// asked for it — see Explicit below and cmd/mavend/keyfile.go.
|
||||
PRF string `json:"prf"`
|
||||
// Explicit marks the "rewrite cold-start key" button rather than a
|
||||
// plain step-up. Only then may the daemon replace a blob that is
|
||||
// already on disk.
|
||||
Explicit bool `json:"explicit"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||
@@ -222,26 +258,22 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// If the daemon is locked (cold-start), send the credential's public key
|
||||
// over IPC so mavend can unwrap its encryption key and open the store.
|
||||
// The public key comes from the local credential store (it was stored
|
||||
// during enrollment). Non-fatal: if IPC doesn't support Unlock or the
|
||||
// daemon is already unlocked, the call is a no-op on the server side.
|
||||
// Cold-start unlock and key wrapping, both keyed on the PRF secret this
|
||||
// assertion just produced. The secret is used here and dropped; it is
|
||||
// never stored on this side, and it must never be logged — unlike a
|
||||
// signature it does not expire, so one copy in a proxy log or a HAR file
|
||||
// is permanent access to the wrapped blob.
|
||||
//
|
||||
// Order matters: unlock first (if the daemon is locked there is nothing to
|
||||
// wrap yet), then wrap. Both are best-effort, because the assertion itself
|
||||
// is valid either way.
|
||||
if h.encryptFn != nil {
|
||||
publicKey, _, err := h.store.Lookup(credID)
|
||||
if err == nil && publicKey != nil {
|
||||
if secret, err := webauthn.DecodePRFResult(body.PRF); err != nil {
|
||||
log.Printf("webauthn: no usable PRF secret from credential %s: %v", credID, err)
|
||||
} else {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.encryptFn.Unlock(ctx, publicKey); err != nil {
|
||||
log.Printf("webauthn: unlock via credential %s: %v", credID, err)
|
||||
// Non-fatal: assertion succeeded; if the daemon stays locked
|
||||
// the user will see errors on subsequent pages, but the
|
||||
// assertion itself is valid.
|
||||
} else {
|
||||
log.Printf("webauthn: daemon unlocked via credential %s", credID)
|
||||
}
|
||||
} else if err != nil {
|
||||
log.Printf("webauthn: lookup credential %s for unlock: %v", credID, err)
|
||||
h.coldStart(ctx, credID, secret, body.Explicit)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,3 +285,56 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("webauthn: asserted credential %s", credID)
|
||||
json.NewEncoder(w).Encode(map[string]string{"credential_id": credID})
|
||||
}
|
||||
|
||||
// coldStart unlocks a locked daemon with this assertion's PRF output and then
|
||||
// asks it to wrap the at-rest key. Never fatal: a locked or unreachable daemon
|
||||
// does not invalidate the step-up.
|
||||
//
|
||||
// # The legacy retry
|
||||
//
|
||||
// A box enrolled before Vikunja #14 has a v1 blob, wrapped under the
|
||||
// credential PUBLIC key. The PRF secret cannot open it, and this handler is
|
||||
// the only caller of Unlock, so without a second attempt that box could never
|
||||
// cold-start again: it would sit locked while a perfectly good passkey was
|
||||
// asserted, and the only way back in would be putting MAVEN_DB_KEY into the
|
||||
// environment — the exact thing cold-start unlock exists to avoid.
|
||||
//
|
||||
// So a failed PRF unlock is retried with the public key from the credential
|
||||
// store. That is not a weaker fallback being offered to new deployments:
|
||||
// nothing writes v1 any more, and a v2 blob does not open under a public key
|
||||
// either. It is a one-way door out of the old format, and the operator is told
|
||||
// to walk through it.
|
||||
func (h *PasskeyHandle) coldStart(ctx context.Context, credID string, secret []byte, explicit bool) {
|
||||
legacy := false
|
||||
err := h.encryptFn.Unlock(ctx, secret)
|
||||
if err != nil && !errors.Is(err, ipc.ErrUnknownMethod) {
|
||||
if pub, _, lerr := h.store.Lookup(credID); lerr == nil && len(pub) > 0 {
|
||||
if err2 := h.encryptFn.Unlock(ctx, pub); err2 == nil {
|
||||
err, legacy = nil, true
|
||||
}
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, ipc.ErrUnknownMethod):
|
||||
// Env-key mode: the daemon was never locked and has no UnlockFn. Not
|
||||
// a failure, and the old code logged it as one on every assertion.
|
||||
case err != nil:
|
||||
log.Printf("webauthn: unlock via credential %s failed: %v", credID, err)
|
||||
case legacy:
|
||||
log.Printf("SECURITY: webauthn: daemon unlocked from a LEGACY v1 wrapped key using credential %s. That blob is derived from the credential public key, which sits in passkeys.json beside it, so it protects nothing. Press \"rewrite cold-start key\" on this page to replace it with a v2 blob.", credID)
|
||||
default:
|
||||
log.Printf("webauthn: daemon reports unlocked, credential %s", credID)
|
||||
}
|
||||
|
||||
// explicit=false means "write the blob only if there is none". The daemon
|
||||
// enforces that; sending the flag is the whole of this side's part in it.
|
||||
switch err := h.encryptFn.StoreEncryptionKey(ctx, secret, explicit); {
|
||||
case err == nil && explicit:
|
||||
log.Printf("webauthn: cold-start key rewritten under credential %s", credID)
|
||||
case err == nil:
|
||||
case errors.Is(err, ipc.ErrUnknownMethod):
|
||||
// No key to wrap: a plaintext dev store, or a daemon still locked.
|
||||
default:
|
||||
log.Printf("webauthn: wrap encryption key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+70
-11
@@ -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:
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# $connection_upgrade — WebSocket upgrade helper for the maven.<domain> block
|
||||
# in nginx.conf. Install this ONLY if your nginx does not already define
|
||||
# $connection_upgrade somewhere in the http context.
|
||||
#
|
||||
# It is a separate file because nginx treats a duplicate `map` directive as a
|
||||
# fatal configuration error, not a warning: if this block were inside
|
||||
# nginx.conf and your setup already had one (nginx-panel and most WebSocket
|
||||
# recipes ship one), the next `nginx -s reload` would fail the config test and
|
||||
# nginx would refuse to come back up — taking every other site on the box down
|
||||
# with it, not just maven.
|
||||
#
|
||||
# Check before installing:
|
||||
# grep -rn 'connection_upgrade' /etc/nginx/
|
||||
# Nothing? Drop this in /etc/nginx/conf.d/ and reload. Something already there?
|
||||
# Skip this file entirely; nginx.conf works as-is.
|
||||
#
|
||||
# Verify either way before reloading:
|
||||
# nginx -t
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
+78
-45
@@ -12,54 +12,43 @@
|
||||
# On a different box, replace both addresses with that box's wg and LAN IPs.
|
||||
# Do NOT "fix" a failed bind by reverting to `listen 80` (all interfaces) —
|
||||
# that removes the only access control these containers have.
|
||||
|
||||
# maven.<domain> → mavweb (docker-compose.yml publishes it on 127.0.0.1:9201).
|
||||
# Same bind + ACL as the siblings, and for a stronger reason: mavweb serves
|
||||
# POST /tools, which defines argv that internal/tool EXECUTES, plus POST
|
||||
# /routines, /api/revert and /api/chat (Vikunja #317). Without
|
||||
# -webauthn-origin/-webauthn-rpid mavweb has no auth of its own, so this block
|
||||
# is the auth. If you add TLS and a basic-auth/oauth2-proxy layer, keep the
|
||||
# allow/deny anyway — belt and braces on an RCE surface.
|
||||
#
|
||||
# WebSocket upgrade matters here: /ws carries push-to-talk audio, so the
|
||||
# Upgrade/Connection headers below are required, not decoration. The map keeps
|
||||
# `Connection: upgrade` off plain requests; it sits in the http context, which
|
||||
# is where sites-available files are included — if your nginx already defines
|
||||
# $connection_upgrade, drop this block.
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
# BEFORE YOU RELOAD — two ways this file takes nginx down, both host-side.
|
||||
# These blocks are for the HOST nginx, not for anything inside the compose;
|
||||
# the containers publish on 127.0.0.1 and have no nginx of their own.
|
||||
#
|
||||
# 1. Binding an address that does not exist yet. `listen 10.42.0.1:80` fails
|
||||
# with EADDRNOTAVAIL if wg0 is down, and nginx exits rather than starting
|
||||
# without it — so a reboot that brings nginx up before WireGuard leaves the
|
||||
# box with no web at all. Allow the bind to succeed regardless:
|
||||
# sysctl -w net.ipv4.ip_nonlocal_bind=1
|
||||
# echo 'net.ipv4.ip_nonlocal_bind = 1' > /etc/sysctl.d/99-nginx-bind.conf
|
||||
# Ordering nginx after the wg interface works too, but only until the next
|
||||
# time the tunnel restarts.
|
||||
#
|
||||
# 2. A duplicate $connection_upgrade map. That is a fatal config error, so the
|
||||
# map now lives in nginx-upgrade-map.conf and is installed separately —
|
||||
# read the note at the top of that file first.
|
||||
#
|
||||
# `nginx -t` catches the second and not the first. Run it anyway, every time.
|
||||
#
|
||||
# BLOCK ORDER IS LOAD-BEARING. nginx serves the first block for a given listen
|
||||
# address when no server_name matches, so whichever block comes first here
|
||||
# answers requests with an unknown or absent Host header. That must not be
|
||||
# mavweb: it is the one surface in this file that can define and run argv. The
|
||||
# nexus block is marked default_server so the choice is explicit rather than a
|
||||
# consequence of file order, and the maven block sits last as a second guard.
|
||||
# If you add a block, do not put it above nexus. If another site file already
|
||||
# claims default_server on 10.42.0.1:80 or 192.168.1.104:80, nginx refuses to
|
||||
# start with "a duplicate default server" — drop the two keywords here and rely
|
||||
# on the block order instead.
|
||||
#
|
||||
# Every server_name below is a literal for kvmx.ru even though the comments
|
||||
# write maven.<domain>. This file reads like a template and is not one.
|
||||
|
||||
server {
|
||||
listen 10.42.0.1:80;
|
||||
listen 192.168.1.104:80;
|
||||
server_name maven.kvmx.ru;
|
||||
|
||||
allow 10.42.0.0/24;
|
||||
allow 192.168.1.0/24;
|
||||
deny all;
|
||||
|
||||
# push-to-talk uploads raw PCM; the default 1m is enough for a short
|
||||
# utterance but not for a long one.
|
||||
client_max_body_size 32m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9201;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s; # an LLM turn can take minutes on the iGPU
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 10.42.0.1:80;
|
||||
listen 192.168.1.104:80;
|
||||
listen 10.42.0.1:80 default_server;
|
||||
listen 192.168.1.104:80 default_server;
|
||||
server_name nexus.kvmx.ru;
|
||||
|
||||
allow 10.42.0.0/24;
|
||||
@@ -110,3 +99,47 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# maven.<domain> → mavweb (docker-compose.yml publishes it on 127.0.0.1:9201).
|
||||
# Same bind + ACL as the siblings, and for a stronger reason: mavweb serves
|
||||
# POST /tools, which defines argv that internal/tool EXECUTES, plus POST
|
||||
# /routines, /api/revert, /api/chat, /api/ptt and /ws (Vikunja #317). Without
|
||||
# -webauthn-origin/-webauthn-rpid mavweb has no auth of its own, so this block
|
||||
# is the auth. If you add TLS and a basic-auth/oauth2-proxy layer, keep the
|
||||
# allow/deny anyway — belt and braces on an RCE surface.
|
||||
#
|
||||
# WebSocket upgrade matters here: /ws carries push-to-talk audio, so the
|
||||
# Upgrade/Connection headers below are required, not decoration. They reference
|
||||
# $connection_upgrade, which this file does NOT define — see
|
||||
# nginx-upgrade-map.conf and point 2 above.
|
||||
|
||||
server {
|
||||
listen 10.42.0.1:80;
|
||||
listen 192.168.1.104:80;
|
||||
server_name maven.kvmx.ru;
|
||||
|
||||
allow 10.42.0.0/24;
|
||||
allow 192.168.1.0/24;
|
||||
deny all;
|
||||
|
||||
# push-to-talk uploads raw PCM; the default 1m is enough for a short
|
||||
# utterance but not for a long one.
|
||||
client_max_body_size 32m;
|
||||
# A 32m upload over a slow link outlives the 60s default on the two body
|
||||
# timeouts, and nginx cuts it at exactly 60s with a 408 or a 504 that looks
|
||||
# like the turn failed. Raise them with the size, not just proxy_read_timeout.
|
||||
client_body_timeout 300s;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9201;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_send_timeout 300s; # pushing the PCM upstream, same reason
|
||||
proxy_read_timeout 300s; # an LLM turn can take minutes on the iGPU
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,40 @@
|
||||
"cooldown": "24h"
|
||||
},
|
||||
|
||||
"mcp": {
|
||||
"timeout": "15s",
|
||||
"servers": [
|
||||
{
|
||||
"name": "vikunja",
|
||||
"url": "http://192.168.1.104:9100/mcp",
|
||||
"allow_private": true,
|
||||
"allow_tools": ["list_projects", "list_tasks", "get_task_details", "create_task"],
|
||||
"max_tools": 6,
|
||||
"enabled": false
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
"smarthome": {
|
||||
"provider": "homeassistant",
|
||||
"url": "http://192.168.1.50:8123",
|
||||
"token": "${HA_TOKEN}",
|
||||
"domains": ["light", "switch", "sensor"],
|
||||
"max_entities": 40,
|
||||
"timeout": "10s",
|
||||
"refresh": "15m",
|
||||
"enabled": false
|
||||
},
|
||||
|
||||
"netscan": {
|
||||
"subnets": ["192.168.1.0/24"],
|
||||
"ports": [22, 80, 443, 8080],
|
||||
"timeout": "400ms",
|
||||
"rate": 100,
|
||||
"max_hosts": 256,
|
||||
"enabled": false
|
||||
},
|
||||
|
||||
"nexus": { "url": "http://nexus:9740" },
|
||||
"praxis": { "url": "http://praxis:8989" },
|
||||
"hexis": { "url": "http://hexis:9741" },
|
||||
|
||||
+9
-2
@@ -131,16 +131,23 @@ services:
|
||||
# "-password-file", "/run/secrets/imap.password",
|
||||
# "-mailbox", "INBOX",
|
||||
# "-interval", "15m",
|
||||
# "-state", "/var/lib/maven/mail-seen.json"]
|
||||
# "-state", "/var/lib/mavmaild/mail-seen.json"]
|
||||
# depends_on: [mavend]
|
||||
# volumes:
|
||||
# - sockets:/run/maven
|
||||
# - dbdata:/var/lib/maven
|
||||
# # Its OWN volume, not dbdata. The whole point of a separate reader is
|
||||
# # that a compromise on either side does not reach the other, and dbdata
|
||||
# # is the encrypted database. The reader needs one JSON file of UIDs and
|
||||
# # gets a volume that holds nothing else, so neither can be restored from
|
||||
# # a backup of the other.
|
||||
# - maildata:/var/lib/mavmaild
|
||||
# - ./deploy/imap.password:/run/secrets/imap.password:ro
|
||||
|
||||
volumes:
|
||||
dbdata:
|
||||
sockets:
|
||||
# maildata — the mail reader's seen-UID file, and nothing else. See mavmaild.
|
||||
maildata:
|
||||
|
||||
networks:
|
||||
default:
|
||||
|
||||
+101
-22
@@ -1,27 +1,106 @@
|
||||
# Plan: Vision — Image Understanding Capability
|
||||
|
||||
**Goal:** Maven can "see" — accept images (from mavweb upload, Telegram, or filesystem paths), run vision inference via a local or remote multimodal model, and answer questions about the image content or extract structured information.
|
||||
**Goal:** Maven can "see" — accept images (from mavweb upload, Telegram, or filesystem paths), store them, run inference via a **local** multimodal model, and answer questions about the image content or extract text from it.
|
||||
|
||||
**Done when:**
|
||||
- Vision model backend is configurable: local multimodal LLM (e.g., LLaVA, Qwen-VL via `llama-server` mmproj) or remote API
|
||||
- `internal/vision/` package handles image preprocessing, model inference, result parsing
|
||||
- Voice/text commands like "что на картинке?" or "прочитай текст с экрана" route to the vision handler
|
||||
- Extracted information can be written as facts/notes through `ipc.CoreAPI`
|
||||
- Telegram image messages are processed through the same pipeline
|
||||
**Status (2026-08-01):** intake, storage, config seam and the provider are shipped. The
|
||||
describing half is **BLOCKED on a model download** — see "What is blocked" below.
|
||||
|
||||
**Scope:**
|
||||
- New `internal/vision/` package — image loader (Go stdlib `image` + `golang.org/x/image`), inference client
|
||||
- New config block: `voice.vision` in `config.Config` — `{enabled, provider, model_path, mmproj_path, remote_url}`
|
||||
- Router intent extension: new `IntentVision` or reuse `IntentQuery` with a vision flag
|
||||
- Reuses `internal/llm.Client` for API-compatible backends (OpenAI-compatible vision API)
|
||||
- Reuses `internal/ipc.CoreAPI` for writing extracted data
|
||||
## What shipped
|
||||
|
||||
**Steps:**
|
||||
1. Create `internal/vision/provider.go` — `Provider` interface with `Describe(image []byte, prompt string) (string, error)` and `ExtractText(image []byte) (string, error)`
|
||||
2. Implement `LocalProvider` — spawns `llama-server` with mmproj, sends multimodal chat completion requests
|
||||
3. Implement `RemoteProvider` — calls an OpenAI-compatible vision API endpoint, reuses `internal/llm.Client`
|
||||
4. Create `internal/vision/processor.go` — image preprocessing (resize, format conversion to JPEG/PNG, base64 encoding)
|
||||
5. Wire vision into `cmd/mavend/voice.go:reactiveHandler` — detect vision intent from router (new `IntentVision` or a `Slots.HasImage` flag)
|
||||
6. Add IPC method `MethodDescribeImage` for programmatic access (mavweb upload, telegram bot)
|
||||
7. Add vision config block to `config.Config` and wire in `cmd/mavend/main.go`
|
||||
8. Test with a local multimodal model: send an image via mavweb, verify description and text extraction
|
||||
| Piece | Where |
|
||||
|---|---|
|
||||
| Blob store (content-addressed, retention-pruned) | `internal/media/store.go` |
|
||||
| Image decode / flatten / downscale / JPEG | `internal/media/image.go` |
|
||||
| `Provider` seam + `Disabled` floor + `LocalProvider` | `internal/vision/vision.go` |
|
||||
| Store-then-describe orchestration, re-runnable | `internal/vision/intake.go` |
|
||||
| Config blocks `media` and `vision` | `internal/config/config.go` |
|
||||
| IPC method `describe_image` (`AuthRead`) | `internal/ipc/{wire,api,client,server}.go`, `internal/auth/policy.go` |
|
||||
| Daemon wiring + hourly retention prune | `cmd/mavend/vision.go` |
|
||||
|
||||
`internal/media` is deliberately shared: hearing (#253) and speaker recognition (#255) have
|
||||
the same intake problem — a blob arrives, gets stored, gets described — and they store their
|
||||
audio in the same place under the same retention.
|
||||
|
||||
## Design decisions worth knowing
|
||||
|
||||
**Store before describe.** `Intake.Accept` writes the blob to disk *first*, then asks the
|
||||
model. If the model is missing or broken — which is this box's actual state — the answer is
|
||||
"it's kept, I can't read it yet" with a content-addressed id, and `Intake.Rerun(id, question)`
|
||||
describes it later. Nothing is lost to a missing model.
|
||||
|
||||
**No `RemoteProvider`.** The original step 3 called for "an OpenAI-compatible vision API
|
||||
endpoint". Refused. The surviving hard constraint in CLAUDE.md after "never phones home" was
|
||||
deprecated is *no cloud model, inference stays on the box*, and a photo of his flat is the
|
||||
worst possible exception. `vision.NewLocal` therefore validates the endpoint at construction:
|
||||
loopback, a private IP, or `localhost`. A hostname is refused too — it could resolve anywhere,
|
||||
and resolving it would mean trusting DNS with his pictures.
|
||||
|
||||
**Blobs are not in the database.** The sqlite store is small, encrypted and read every tick;
|
||||
a 40 MB blob has no business there. What lands in the database is the *text* the blob produced,
|
||||
as an ordinary note (`source: media:image:<id-prefix>`), and only when the caller asks for it
|
||||
(`save_note`). Glancing at a screenshot is not the same act as remembering it.
|
||||
|
||||
**Images are never search input and never embedded.** Only the derived description
|
||||
participates in recall, and only after he can see it as a note.
|
||||
|
||||
**Retention is enforced by a loop, not by a promise.** `media.retention` defaults to 7 days
|
||||
and `cmd/mavend` prunes hourly, starting at boot. A store that grows forever would be the real
|
||||
failure mode of this capability.
|
||||
|
||||
**No webp.** The stdlib has no webp decoder and this repo takes no new dependencies (the box
|
||||
is offline). `media.SniffImage` recognises webp well enough to refuse it *by name*, so the log
|
||||
says "webp is not supported" instead of "not an image". Telegram sends webp for stickers; that
|
||||
is a known gap, not a mystery.
|
||||
|
||||
**Text extraction is not a second method.** "прочитай текст с картинки" is a prompt. A VLM has
|
||||
no separate OCR mode to select, and a second interface method would only duplicate the first.
|
||||
|
||||
## What is blocked, and on what
|
||||
|
||||
There is **no vision-capable gguf and no mmproj file on this box**. Checked 2026-08-01:
|
||||
|
||||
```
|
||||
/mnt/hdd1/llms/{Bonsai,LFM2.5,llama3.2,ministral,nemotron3-nano,qwen3,qwen3.5}
|
||||
```
|
||||
|
||||
— sixteen ggufs, all text-only, no `*mmproj*` anywhere. The resident Qwen3-1.7B is text-only
|
||||
by construction, so vision needs a *second* model. The ≤1.7B ceiling in CLAUDE.md is about the
|
||||
resident router/phraser, not about a second model loaded on demand — but iGPU VRAM still is,
|
||||
so keep it small.
|
||||
|
||||
To unblock, download one pair to `/mnt/hdd1/llms/vision/` (bind-mounted to
|
||||
`/opt/maven/models/llm`), a gguf **and** its mmproj:
|
||||
|
||||
- `Qwen2.5-VL-3B-Instruct` (Q4_K_M + `mmproj-F16.gguf`) — the safe default; reads Russian, and
|
||||
its OCR is the best of this size class.
|
||||
- `SmolVLM2-2.2B-Instruct` — smaller and faster, weaker at Cyrillic text in images.
|
||||
- `moondream2` — smallest, English-only in practice. Do not bother, per the sub-500M lesson.
|
||||
|
||||
Then run a second llama-server on 8081 with `--mmproj`, point `vision.endpoint` at it, and
|
||||
walk the QA steps on Vikunja #252.
|
||||
|
||||
## Config
|
||||
|
||||
```json
|
||||
"media": { "dir": "media", "retention": "168h", "max_bytes": 67108864 },
|
||||
"vision": {
|
||||
"enabled": true,
|
||||
"endpoint": "http://127.0.0.1:8081",
|
||||
"model": "qwen2.5-vl-3b",
|
||||
"max_dim": 896,
|
||||
"max_tokens": 300,
|
||||
"timeout": "90s"
|
||||
}
|
||||
```
|
||||
|
||||
Both absent by default. No `media` block ⇒ `describe_image` does not exist at all; a `media`
|
||||
block with no `vision` block ⇒ images are stored and honestly not described.
|
||||
|
||||
## Still open
|
||||
|
||||
- **Router intent.** "что на картинке?" does not route anywhere yet. Adding an intent is
|
||||
premature while nothing can answer it; the IPC method is the surface a Telegram photo or a
|
||||
mavweb upload calls today.
|
||||
- **Telegram photo path** in `mavpoll` (download the file, call `DescribeImage`).
|
||||
- **mavweb upload page** and a `/media` listing so stored blobs are visible and deletable from
|
||||
the authed surface.
|
||||
|
||||
+117
-24
@@ -1,28 +1,121 @@
|
||||
# Plan: Hearing — Audio Stream Monitoring & Meeting Summarization
|
||||
# Plan: Hearing — Meeting Capture & Summarisation
|
||||
|
||||
**Goal:** Maven can "hear" ambient audio from workpc — microphone input during meetings, system audio — and on demand (or on trigger) produce transcripts, summaries, or extract action items. A typical use case: "Maven, запиши встречу" starts capture, "хватит" stops it, and Maven writes a summary note.
|
||||
**Goal:** "Maven, запиши встречу" starts a recording, "хватит" stops it, and she writes a
|
||||
summary note. The audio stays on the box, is pruned by retention, and nothing is recorded that
|
||||
nobody asked for.
|
||||
|
||||
**Done when:**
|
||||
- `internal/audio/capture.go` — remote microphone capture client (receives PCM stream from workpc over WebSocket or the existing voice TCP protocol)
|
||||
- `internal/stt/` — streaming transcription (uses existing `stt.Transcriber` interface, extended with streaming support)
|
||||
- Meeting capture triggered by voice command (IntentCapture) or configurable keyword ("maven record")
|
||||
- Raw audio is either streamed to STT in real-time or saved to a WAV file and transcribed after capture ends
|
||||
- Transcription + LLM summary is written as a note (`source:capture:meeting`) through `ipc.CoreAPI`
|
||||
- New `mavheary` module (`cmd/mavheard/`) — the workpc-side agent that captures mic/speaker audio and streams it to mavend
|
||||
**Status (2026-08-01):** the recorder, the storage, the chunked transcription, the map-reduce
|
||||
summariser, the config seam and the four IPC methods are shipped and tested. What is not
|
||||
shipped is the workpc-side microphone agent and the router intent — see "Still open".
|
||||
|
||||
**Scope:**
|
||||
- New `cmd/mavheard/` — workpc-side agent: captures microphone (PortAudio or ALSA `arecord`), streams over WebSocket to mavend
|
||||
- `internal/audio/` extended with capture types: `MicCapture`, `SystemCapture`, `FileCapture`
|
||||
- `internal/stt/stt.go` extended with `StreamingTranscriber` interface (or reuse existing with chunked input)
|
||||
- Router: new `IntentCapture` intent for start/stop commands
|
||||
- Reuses `internal/llm.Client` for summarization
|
||||
- Reuses `internal/voice/server.go` TCP protocol for streaming audio
|
||||
## What shipped
|
||||
|
||||
**Steps:**
|
||||
1. Create `cmd/mavheard/main.go` — workpc-side daemon: captures microphone via `arecord` pipe or PortAudio, opens WebSocket or TCP connection to mavend, streams PCM frames
|
||||
2. Create `internal/audio/capture.go` — `Capture` interface: `Start()`, `Stop()`, `AudioCh <-chan Audio`; implement `MicCapture` (reads from `mavheard` stream) and `FileCapture` (reads WAV)
|
||||
3. Extend `internal/stt/stt.go` — add `TranscribeStream(ctx, audio <-chan Audio) (string, error)` to `Transcriber` interface; `Stub` returns empty; `Remote` forwards chunks to worker socket
|
||||
4. Add `IntentCapture` to `internal/router/intent.go` — slots: `Action` ("start"/"stop"/"status"), `Duration`
|
||||
5. Wire capture handler in `cmd/mavend/voice.go:reactiveHandler` — start = spawn goroutine receiving audio, stream to STT; stop = finalize, send to LLM for summarization, write note via `WriteNote`
|
||||
6. Add capture config to `voice` block in `config.Config` — `{capture_enabled, capture_timeout}`
|
||||
7. Test with a recorded WAV file — simulate a meeting, verify transcription + summary note is created
|
||||
| Piece | Where |
|
||||
|---|---|
|
||||
| Session state machine: start / append / stop / abort / status | `internal/capture/capture.go` |
|
||||
| Map-reduce summarisation against `n_ctx` 4096 | `internal/capture/summarize.go` |
|
||||
| Audio blobs in the shared store, pruned by `media.retention` | `internal/media` (from #252) |
|
||||
| Config block `capture`, off by default | `internal/config/config.go` |
|
||||
| IPC `capture_start` / `capture_append` / `capture_stop` / `capture_status` | `internal/ipc/{wire,api,client,server}.go` |
|
||||
| Authority: the three write methods `AuthWrite`, status `AuthRead` | `internal/auth/policy.go` |
|
||||
| Daemon wiring, note write, STT reuse | `cmd/mavend/capture.go` |
|
||||
|
||||
The audio lands in the same content-addressed blob store as images, under the same retention
|
||||
loop, because #252 and #253 have the same intake problem and solving it twice would mean two
|
||||
directories to remember to prune.
|
||||
|
||||
## The refusals, and why
|
||||
|
||||
**Nothing listens.** The original step 8 called for capture "triggered by voice command
|
||||
(IntentCapture) **or configurable keyword ('maven record')**". The keyword half is refused.
|
||||
Noticing a keyword requires listening to the room continuously, which is precisely the
|
||||
behaviour this capability must not have, and the refusal is in the code rather than in a
|
||||
comment: `Recorder.Append` is the only way audio enters, and it returns `ErrNoSession` unless
|
||||
someone explicitly started a session. Audio arriving at an idle core is dropped, not buffered
|
||||
"just in case".
|
||||
|
||||
**Off unless configured, twice over.** No `media` block ⇒ nowhere to keep audio ⇒ the four
|
||||
methods do not exist. No `capture` block with `enabled: true` ⇒ they still do not exist. On an
|
||||
unconfigured box there is no wire path at all that begins a recording. That is the only
|
||||
guarantee worth making here, and it is the reason the hooks use the nil-hook ⇒
|
||||
`ErrUnknownMethod` pattern rather than an in-handler check.
|
||||
|
||||
**A forgotten session ends itself.** `max_minutes` defaults to 120 and is checked on every
|
||||
append, not on a timer that could be missed. Past the cap `Append` returns `ErrExpired`
|
||||
permanently, so a client that ignores the error cannot grow the recording; the audio collected
|
||||
before the cap is kept and `Stop` still works.
|
||||
|
||||
**"Забудь, не записывай" leaves nothing behind.** `capture_stop` with `discard: true` throws
|
||||
the session away without storing, transcribing or summarising anything — not a blob with a note
|
||||
saying it was abandoned. Nothing.
|
||||
|
||||
**The transcript is not saved by default.** The summary is written where he will read it; the
|
||||
verbatim record of what other people said in a room is a heavier thing to keep and takes a
|
||||
deliberate `save_transcript: true`. The audio blob is pruned by `media.retention` either way.
|
||||
|
||||
**No second STT.** Step 3 of the original plan extended the `Transcriber` interface with
|
||||
streaming. Not needed and not done: whisper.cpp already runs as `mavsttd`, and `internal/capture`
|
||||
takes the ordinary `stt.Transcriber` the voice path already holds (exposed as
|
||||
`voiceWiring.transcriber`). Long recordings are handed over in five-minute windows —
|
||||
`chunkAudio`, cut on sample boundaries — for the same reason whisper itself works in 30-second
|
||||
windows: an hour of PCM in one call either times out or blocks the voice path for minutes.
|
||||
Capture with voice off is refused rather than degraded, because storing hours of unreadable
|
||||
audio of other people is worse than not recording.
|
||||
|
||||
**Not `AuthStepUp`.** Recording people is invasive enough to argue for the top rung, and it is
|
||||
still wrong: step-up needs a passkey gesture, which the voice path cannot make, so
|
||||
"запиши встречу" could never work by voice — the only way he will actually use this. `AuthWrite`
|
||||
plus the off-unless-configured gate is the honest combination.
|
||||
|
||||
## Long audio against a 4096-token context
|
||||
|
||||
The resident model is a Thinking variant at `n_ctx` 4096, so an hour of transcript does not fit
|
||||
in one prompt and never will. `summarize.go` does map-reduce and nothing cleverer: split the
|
||||
transcript on sentence boundaries into 3000-rune windows (about 1100 Qwen tokens of Russian,
|
||||
leaving room for the persona block, the reasoning and the answer), summarise each, then
|
||||
summarise the summaries. A transcript that fits in one window skips the reduce step.
|
||||
|
||||
Truncation was the alternative and is rejected: a truncated meeting summary reads as complete
|
||||
and is not, and he would act on it. Past `max_chunks` (40, roughly the two-hour cap) the
|
||||
transcript *is* cut, and the summary says so in the note.
|
||||
|
||||
Two degradations are deliberate and both are reported rather than hidden:
|
||||
|
||||
- No llama-server ⇒ transcript, no summary. The words exist.
|
||||
- The reduce call fails ⇒ the per-chunk summaries are returned joined. Real work, not thrown
|
||||
away over the last call.
|
||||
|
||||
The map and reduce prompts contain no first person at all, so the persona's feminine-form rules
|
||||
have nothing to get wrong in them; the reply she actually gives him is phrased by the ordinary
|
||||
replier, which does carry the persona.
|
||||
|
||||
## Config
|
||||
|
||||
```json
|
||||
"media": { "dir": "media", "retention": "168h" },
|
||||
"capture": {
|
||||
"enabled": true,
|
||||
"max_minutes": 120,
|
||||
"stt_window": "5m",
|
||||
"chunk_runes": 3000,
|
||||
"max_chunks": 40,
|
||||
"save_transcript": false
|
||||
}
|
||||
```
|
||||
|
||||
Both absent by default. `capture` alone does nothing without `media`.
|
||||
|
||||
## Still open
|
||||
|
||||
- **`cmd/mavheard`** — the workpc-side microphone agent. Deferred, not refused: the core half
|
||||
is the part with the invariants in it, and a mic client is straightforward once there is a
|
||||
stable wire to stream at. It should be an explicit-start process, not a resident one, for the
|
||||
same reason the recorder has no keyword trigger. The four IPC methods are the wire it will
|
||||
use; `mavenclient` already has the mic plumbing to borrow.
|
||||
- **Router intent.** "запиши встречу" / "хватит" does not route anywhere yet. It needs the
|
||||
`system` intent plus slots, and it needs care: "хватит" is also how someone tells her to stop
|
||||
talking, so the recorder's stop and the speech barge-in must not collide.
|
||||
- **A `/dash` panel** showing a running session, so a recording is visible on a surface and not
|
||||
only in a log line.
|
||||
- **Speaker attribution** — who said what — is #255 and is blocked on a model; see
|
||||
`docs/plans/10-speaker-recognition.md`.
|
||||
|
||||
@@ -1,27 +1,125 @@
|
||||
# Plan: Speaker Recognition
|
||||
|
||||
**Goal:** Maven can distinguish between different speakers on the voice channel — recognize known voices (the user, family members) and tag facts/notes/transcripts with a speaker identity.
|
||||
**Goal:** Maven can tell who is speaking on the voice channel, and tag what she writes with
|
||||
who said it.
|
||||
|
||||
**Done when:**
|
||||
- Speaker embedding extractor (e.g., ECAPA-TDNN or a simple MFCC + GMM) runs on incoming voice PCM before STT
|
||||
- Embedding is compared against enrolled speaker profiles (stored as vectors in the `memory_vectors` table alongside semantic memory)
|
||||
- Unknown speakers are enrolled on first interaction (prompt: "кто это?")
|
||||
- All voice fact/note writes are tagged with `speaker:<id>` in the value/source metadata
|
||||
- Speaker identity is available as context to the router, phraser, and replier ("ok, <name>")
|
||||
**Status (2026-08-01, Vikunja #255):** the enrolment half is shipped. The recognising half is
|
||||
**BLOCKED on a model download** — there is no speaker-embedding model on this box, and one
|
||||
was not invented to fill the gap. See "Blocked, and on what" below.
|
||||
|
||||
**Scope:**
|
||||
- New `internal/speaker/` package — enrollment, recognition, embedding extraction
|
||||
- Reuses `internal/store.MemoryStore` for speaker vector storage (same `memory_vectors` table, different `source` prefix)
|
||||
- Reuses `internal/audio` for PCM preprocessing
|
||||
- Integration point: `cmd/mavend/voice.go:HandlePushToTalk` — speaker ID extracted before STT, passed through context
|
||||
## What shipped
|
||||
|
||||
**Steps:**
|
||||
1. Research speaker embedding approaches — simplest floor: MFCC + cosine similarity via `github.com/mjibson/go-dsp` or a pre-trained ONNX model (SpeechBrain ECAPA)
|
||||
2. Create `internal/speaker/recognizer.go` — `Recognizer` interface: `Identify(pcm []float32) (SpeakerID, confidence)`, `Enroll(id, pcm)`
|
||||
3. Create `internal/speaker/store.go` — speaker profile CRUD via `store.MemoryStore`: `Insert("speaker:<id>", embedding, meta)`, `Search(embedding, k)`
|
||||
4. Create `internal/speaker/enroll.go` — enrollment flow: capture N seconds of audio, extract embedding, prompt for name via TTS + STT round-trip
|
||||
5. Wire into `cmd/mavend/voice.go:HandlePushToTalk` — run speaker ID on the PCM before STT; pass speaker ID through `context.Context` to `applyAction`
|
||||
6. Tag all voice-written facts/notes with speaker ID — `Source` becomes `tap:voice:speaker:<id>` or metadata field
|
||||
7. Add IPC methods `MethodEnrollSpeaker`, `MethodListSpeakers`, `MethodRemoveSpeaker`
|
||||
8. Add speaker config block to `voice` in `config.Config` — `{speaker_recognition: true, model_path}`
|
||||
9. Test with 2+ recorded voice samples — verify correct identification and rejection of unknown speakers
|
||||
| Piece | Where | State |
|
||||
|---|---|---|
|
||||
| `Recognizer` — identify, list, get, forget | `internal/speaker/recognizer.go` | done; `Identify` answers `ErrDisabled` until a model exists |
|
||||
| Enrolment — several samples, averaged, re-normalised | `internal/speaker/enroll.go` | done |
|
||||
| Profile shape, id validation, cosine similarity | `internal/speaker/speaker.go` | done |
|
||||
| Profile storage as `speaker:<id>` vectors | `internal/memory` `Catalog` + `internal/store/memory.go` | done, no schema migration |
|
||||
| Config block, off by default | `internal/config` `SpeakerConfig` | done |
|
||||
| `enroll_speaker` / `list_speakers` / `forget_speaker` | `internal/ipc` | done, absent unless configured |
|
||||
| Authority rows | `internal/auth/policy.go` | done — enrol step-up, forget write, list read |
|
||||
| Daemon wiring + honest startup log | `cmd/mavend/speaker.go` | done |
|
||||
| Embedding backend | `newSpeakerEmbedder` | **BLOCKED** — returns nil, seam only |
|
||||
| Tagging voice writes with the speaker | `cmd/mavend/voice.go` | not wired; nothing to tag with yet |
|
||||
|
||||
## Blocked, and on what
|
||||
|
||||
A voiceprint needs a speaker-embedding model. The box was searched: `/mnt/hdd1/llms` holds
|
||||
sixteen ggufs across seven families and every one of them is a text model. There is no ECAPA,
|
||||
no x-vector, no titanet, no wespeaker, and no `.onnx` under `/mnt/hdd1` at all. There are also
|
||||
no enrolment samples, because nothing has ever recorded any.
|
||||
|
||||
To unblock, two things are needed and neither can be done from inside the repo:
|
||||
|
||||
1. **A model.** SpeechBrain ECAPA-TDNN exported to ONNX (`speechbrain/spkrec-ecapa-voxceleb`,
|
||||
192-dim) is the usual choice and runs on CPU in well under a second for a few seconds of
|
||||
audio. Download it per the recipe in `AGENTS.md`, put it beside the other models so the
|
||||
bind mount picks it up, and point `speaker.model_path` at it.
|
||||
2. **An implementation of one function.** `newSpeakerEmbedder` in `cmd/mavend/speaker.go` is
|
||||
the entire seam: give it an ONNX session that turns `audio.Audio` into a `[]float32` and
|
||||
`Identify` starts working. Nothing else changes — not the store, not the protocol, not the
|
||||
authority table, not the handlers. `internal/onnx` already loads the e5 embedder, so the
|
||||
runtime wiring exists to copy.
|
||||
3. **Enrolment samples**, three or more per person, recorded deliberately.
|
||||
|
||||
### Why there is no fallback
|
||||
|
||||
The original plan offered "a simple MFCC + GMM" as the floor. That is refused. MFCC cosine
|
||||
distance is a channel and loudness detector as much as a voice detector: it will happily match
|
||||
two different people who sit at the same distance from the same microphone, and it drifts when
|
||||
the room changes. A general classifier that is sometimes wrong is a nuisance; a **biometric**
|
||||
that is confidently wrong writes false claims about named people into his memory, and then
|
||||
those claims get recalled as fact. For this capability a bad floor is worse than none, so the
|
||||
shipped state is honest absence: `speaker.Disabled`, `ErrDisabled`, and a startup line saying
|
||||
so.
|
||||
|
||||
## The refusals, and why
|
||||
|
||||
- **Unknown speakers are NOT enrolled on first interaction.** The plan's fourth "done when"
|
||||
bullet asked for exactly that, with a TTS "кто это?" prompt. It is refused in
|
||||
`enroll.go`'s doc comment and there is no request shape in the protocol that could express
|
||||
it. Enrolling a voice is taking a biometric of a person; doing it automatically to whoever
|
||||
walks past the microphone does it to guests who are not party to the exchange, and a
|
||||
synthesised question into a room is not consent from whoever happens to answer. Enrolment is
|
||||
an explicit act: an id, a name, and samples recorded for the purpose.
|
||||
- **One sample is not enough.** Three separate utterances and nine seconds minimum. A profile
|
||||
built from one sentence encodes that sentence as much as the person, and the threshold then
|
||||
behaves unpredictably against everything else.
|
||||
- **An unknown voice stays unknown.** Below threshold, `Identify` returns `ErrUnknown` naming
|
||||
the closest profile in the error text for diagnosis, never as an answer. Guessing who is in
|
||||
the room is how false memories about people get written.
|
||||
- **Deletion is one authority rung below enrolment.** Everywhere else in `policy.go` the
|
||||
destructive direction is gated at least as hard as the constructive one. Here that would be
|
||||
backwards: getting rid of a biometric must never be the harder half.
|
||||
- **The voiceprint never crosses the socket.** `ListSpeakersResp` carries ids, names, dates
|
||||
and sample counts. The vector stays in core.
|
||||
- **Off unless configured.** No `speaker` block ⇒ the three methods answer
|
||||
`ErrUnknownMethod`. There is no wire path on a default box that takes a voiceprint.
|
||||
|
||||
## Storage
|
||||
|
||||
Profiles live in the existing `memory_vectors` table under the `speaker:` id prefix, as the
|
||||
plan intended, so there is no migration. What that needed was a wider interface than
|
||||
`memory.Store`: `memory.Catalog` adds `ByPrefix` and `Delete`. `Delete` is the load-bearing
|
||||
one — a voiceprint someone asked to be rid of has to actually go, and a search-only store
|
||||
cannot do that. `InMemoryStore.Insert` also became an upsert by id, matching what the
|
||||
persistent store already did, so re-enrolling replaces a profile instead of stacking a second
|
||||
one behind the first.
|
||||
|
||||
Profiles do not collide with note or fact vectors: they are only ever read through
|
||||
`ByPrefix("speaker:")`, and a note search never returns one because the prefix is not in its
|
||||
query path.
|
||||
|
||||
## Config
|
||||
|
||||
```json
|
||||
"speaker": {
|
||||
"enabled": true,
|
||||
"model_path": "/opt/maven/models/spk/ecapa-voxceleb.onnx",
|
||||
"lib_path": "/opt/maven/lib",
|
||||
"threshold": 0.7,
|
||||
"min_seconds": 2.0
|
||||
}
|
||||
```
|
||||
|
||||
`Recognizes()` requires both `enabled` and a `model_path`, so a half-filled block reads as off
|
||||
rather than as a capability that fails every turn. With `enabled` and no model the daemon still
|
||||
attaches the three methods — profiles can be created, listed and deleted — and logs that
|
||||
recognition is blocked.
|
||||
|
||||
## Still open
|
||||
|
||||
- The embedding backend (above). Everything below waits on it.
|
||||
- **Tagging voice writes.** `Profile.Source("tap:voice")` already produces
|
||||
`tap:voice:speaker:kami`, which is the shape step 6 asked for, but nothing calls it yet:
|
||||
with no recogniser there is no id to tag with. When the model lands, the hook is in the
|
||||
voice path before STT.
|
||||
- **Speaker as router/phraser context.** Same dependency. Note the persona constraint when it
|
||||
arrives: Maven addresses the owner informally and speaks to him, so "ok, <name>" needs care
|
||||
for anyone who is not him.
|
||||
- **An enrolment surface.** The three IPC methods exist; no page drives them. Enrolment is
|
||||
step-up, so it belongs on `/dash` behind a passkey, with a per-profile forget button next to
|
||||
each row — that button is the reason `list_speakers` exists.
|
||||
- **A speaker column on the meeting recorder** (#253). Attributing lines in a transcript is
|
||||
the obvious pairing, and it is the place where getting attribution wrong is most damaging,
|
||||
so it waits for a real model too.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user