Merge branch 'fix/g08' into fix/integrated

# Conflicts:
#	internal/store/migrations.go
This commit is contained in:
kami
2026-08-01 14:38:39 +04:00
39 changed files with 2753 additions and 363 deletions
+7
View File
@@ -53,6 +53,13 @@ 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
+72 -22
View File
@@ -34,6 +34,7 @@ import (
"errors"
"fmt"
"log"
"sync"
"time"
"github.com/kami/maven/internal/capture"
@@ -45,10 +46,12 @@ import (
"github.com/kami/maven/internal/store"
)
// captureSummaryTimeout — the budget for one Stop, which is a map-reduce over
// 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.
// 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
@@ -70,6 +73,12 @@ type captureWiring struct {
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
@@ -79,7 +88,7 @@ type captureWiring struct {
// 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(keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, emb router.Embedder, cfg *config.Config) *captureWiring {
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
}
@@ -113,7 +122,7 @@ func newCaptureWiring(keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring,
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}
return &captureWiring{rec: rec, st: st, emb: emb, cfg: cc, now: time.Now, ctx: ctx, wg: wg}
}
// start handles ipc.MethodCaptureStart.
@@ -127,6 +136,7 @@ func (c *captureWiring) start(_ context.Context, req ipc.CaptureStartReq) (ipc.C
return ipc.CaptureStartResp{
Label: s.Label,
Started: s.Started,
Token: s.Token,
MaxSeconds: int(c.rec.MaxDuration().Seconds()),
}, nil
}
@@ -135,7 +145,7 @@ func (c *captureWiring) start(_ context.Context, req ipc.CaptureStartReq) (ipc.C
// 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.Audio)
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())
@@ -150,21 +160,26 @@ func (c *captureWiring) append(_ context.Context, req ipc.CaptureAppendReq) (ipc
// stop handles ipc.MethodCaptureStop.
//
// The error handling here mirrors vision's, and for the same reason: the audio is
// stored first, so a transcription or summary failure returns what exists rather
// than nothing. A response can carry a blob id with no transcript (STT failed,
// re-runnable), or a transcript with no summary (the model failed, the words are
// kept) — both are degraded successes and neither is an error to the caller.
// 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() {
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)
res, err := c.rec.Stop(ctx, req.Token)
resp := ipc.CaptureStopResp{
BlobID: res.BlobID,
Label: res.Label,
@@ -183,19 +198,47 @@ func (c *captureWiring) stop(ctx context.Context, req ipc.CaptureStopReq) (ipc.C
log.Printf("capture: %q partially finished: %v", res.Label, err)
}
if id, werr := c.writeNotes(ctx, res); werr != nil {
log.Printf("capture: note write for %q failed: %v", res.Label, werr)
} else {
resp.NoteID = id
}
log.Printf("capture: finished %q — %s of audio, %d summary chunk(s)",
res.Label, res.Duration.Round(time.Second), res.Chunks)
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 summary note's id, or 0 when there
// was no summary to write.
// 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
@@ -212,6 +255,13 @@ func (c *captureWiring) writeNotes(ctx context.Context, res capture.Result) (int
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 {
@@ -251,8 +301,8 @@ func (c *captureWiring) status(_ context.Context) (ipc.CaptureStatusResp, error)
// 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(srv *ipc.Server, keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, cfg *config.Config) {
cw := newCaptureWiring(keeper, st, voiceW, phr, embedderOf(voiceW), cfg)
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
}
+118
View File
@@ -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)
}
}
+9 -5
View File
@@ -394,6 +394,11 @@ 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.
@@ -402,11 +407,11 @@ func run(args []string) error {
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, srv, st, embedderOf(voiceW), cfg)
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(srv, keeper, st, voiceW, phr, 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.
@@ -603,8 +608,8 @@ func run(args []string) error {
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
wireMailIntake(srv, st, phr, cfg, evBus)
wireModelSwap(srv, phr, cfg)
keeper := wireVision(ctx, srv, st, embedderOf(voiceW), cfg)
wireCapture(srv, keeper, st, voiceW, 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.
@@ -670,7 +675,6 @@ func run(args []string) error {
}
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
+125 -20
View File
@@ -13,8 +13,11 @@ import (
"github.com/kami/maven/internal/webfetch"
)
// mcpRefreshInterval — how often the manager re-dials a server that is down.
// The manager applies its own backoff on top, so this being short is cheap.
// 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
@@ -29,9 +32,16 @@ type mcpWiring struct {
st *store.Store
}
// wireMCP builds the manager, connects, and proposes what it found. It never
// fails the daemon: a server that is unreachable at boot is logged and retried,
// because Maven starting is not contingent on someone else's process.
// 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 {
@@ -43,6 +53,7 @@ func wireMCP(cfg *config.Config, st *store.Store) *mcpWiring {
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 {
@@ -52,30 +63,58 @@ func wireMCP(cfg *config.Config, st *store.Store) *mcpWiring {
log.Printf("mcp: not wired: %v", err)
return nil
}
w := &mcpWiring{mgr: mgr, st: st}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
mgr.Connect(ctx)
w.propose(ctx)
return w
return &mcpWiring{mgr: mgr, st: st}
}
// propose writes a 'proposed' allowlist row for every discovered tool. 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.
// 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.
//
// Re-running on every boot is idempotent — ProposeMCPTool never touches an
// existing row, so a tool he disabled stays disabled and one he enabled keeps
// the cmd he enabled it with.
// 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 := 0
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
@@ -83,19 +122,82 @@ func (w *mcpWiring) propose(ctx context.Context) {
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, now)
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
@@ -104,6 +206,9 @@ 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 {
+19
View File
@@ -31,6 +31,23 @@ func TestWireMCPOffWhenUnconfigured(t *testing.T) {
}
}
// 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) {
@@ -42,6 +59,7 @@ func TestWireMCPUnreachableServerIsNotFatal(t *testing.T) {
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)
@@ -67,6 +85,7 @@ func TestWireMCPPrivateURLRefusedWithoutAllowPrivate(t *testing.T) {
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")
+35 -10
View File
@@ -7,11 +7,14 @@
// 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 twice over: no `media` block ⇒ nowhere to keep the
// bytes, so the method does not exist; no `vision` block with enabled + a local
// endpoint ⇒ the store is wired but the describing half refuses, and the method
// still does not exist. A surface cannot make Maven look at pictures by merely
// sending one.
// 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:
//
@@ -29,6 +32,7 @@ import (
"fmt"
"log"
"path/filepath"
"sync"
"time"
"github.com/kami/maven/internal/config"
@@ -64,12 +68,14 @@ func openMediaStore(cfg *config.Config) *mediaKeeper {
if !filepath.IsAbs(dir) && cfg.StateDir != "" {
dir = filepath.Join(cfg.StateDir, dir)
}
st, err := media.Open(dir, cfg.Media.MaxBytes, time.Duration(cfg.Media.Retention))
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", st.Dir(), st.Retention())
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}
}
@@ -159,6 +165,12 @@ func (v *visionIntake) describe(ctx context.Context, req ipc.DescribeImageReq) (
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
@@ -205,6 +217,12 @@ func (v *visionIntake) describe(ctx context.Context, req ipc.DescribeImageReq) (
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.
@@ -221,7 +239,7 @@ func (v *visionIntake) writeNote(ctx context.Context, res vision.Result) (int64,
}
}
source := "media:image:" + res.Blob.ID[:12]
return v.st.WriteNote(ctx, v.now(), res.Description, vec, source)
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.
@@ -241,12 +259,19 @@ func sourceOrDefault(s string) string {
// 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, srv *ipc.Server, st *store.Store, emb router.Embedder, cfg *config.Config) *mediaKeeper {
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
}
go keeper.runPrune(ctx)
// 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 {
+72
View File
@@ -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")
}
}