vision: scope the note, settle the contract, wait for the prune
Saving a description writes recall corpus. writeNote embeds it under media:image:<id>, a source no enrollment owns, and the method sits at AuthRead, so any enrolled module could put a small VLM's guess into what Maven knows and have it come back in a later turn as something she believes. The describing half stays a read; save_note is now held to the same source-scope rule WriteFact is, and the stored text carries a marker saying it came off a picture. Three doc comments said the method exists only when vision is enabled and the code says otherwise. The code is right, and storing without describing is the state this box is in, so the comments were corrected rather than the behaviour. A request carrying both data and id used to take the id branch and drop the bytes without a word; it is refused. A media dir that cannot be created and a vision endpoint that is a typo were logged at wiring time and the capability just stayed off, which is the hardest kind of misconfiguration to notice. Both fail at startup. runPrune was the one loop started with a bare go and not in the daemon's WaitGroup, so shutdown did not wait for a prune that was deleting files. Found in review of #72.
This commit is contained in:
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/netscan"
|
||||
"github.com/kami/maven/internal/smarthome"
|
||||
"github.com/kami/maven/internal/vision"
|
||||
"github.com/kami/maven/internal/update"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
@@ -693,6 +694,12 @@ type MediaConfig struct {
|
||||
|
||||
// MaxBytes — per-blob cap. 0 ⇒ media.DefaultMaxBytes (64 MiB).
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
|
||||
// MaxTotalBytes — whole-store cap. 0 ⇒ media.DefaultMaxTotalBytes (4 GiB).
|
||||
// The per-blob cap bounds one call; this one bounds the sum of them, which
|
||||
// is what actually decides whether the disk mavend's database lives on can
|
||||
// be filled from outside.
|
||||
MaxTotalBytes int64 `json:"max_total_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// StoreDir reports the configured blob directory, or "" when media is not
|
||||
@@ -1428,6 +1435,36 @@ func (c *Config) validate() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// A media dir that cannot be created, or a vision endpoint that is a typo,
|
||||
// used to be logged at wiring time and the capability just stayed off. A
|
||||
// capability silently not existing is the hardest kind of misconfiguration
|
||||
// to notice, so both fail here instead.
|
||||
if c.Media != nil {
|
||||
if c.Media.StoreDir() == "" {
|
||||
return errors.New("media.dir is required when a media block is present")
|
||||
}
|
||||
if c.Media.MaxBytes < 0 || c.Media.MaxTotalBytes < 0 {
|
||||
return errors.New("media: max_bytes and max_total_bytes cannot be negative")
|
||||
}
|
||||
if c.Media.MaxTotalBytes > 0 && c.Media.MaxBytes > c.Media.MaxTotalBytes {
|
||||
return fmt.Errorf("media: max_bytes %d is above max_total_bytes %d",
|
||||
c.Media.MaxBytes, c.Media.MaxTotalBytes)
|
||||
}
|
||||
}
|
||||
if c.Vision != nil && c.Vision.Enabled {
|
||||
if strings.TrimSpace(c.Vision.Endpoint) == "" {
|
||||
return errors.New("vision.enabled set but vision.endpoint is empty")
|
||||
}
|
||||
if err := vision.ValidateEndpoint(c.Vision.Endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Media.StoreDir() == "" {
|
||||
return errors.New("vision.enabled set but there is no media block to keep the bytes in")
|
||||
}
|
||||
}
|
||||
if c.Capture.Records() && c.Media.StoreDir() == "" {
|
||||
return errors.New("capture.enabled set but there is no media block to keep the audio in")
|
||||
}
|
||||
if len(c.MorningRoutines) > 0 {
|
||||
if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil {
|
||||
return err
|
||||
|
||||
@@ -221,3 +221,40 @@ func TestSpeakerBlockParsesFromJSON(t *testing.T) {
|
||||
t.Errorf("thresholds = %+v", cfg.Speaker)
|
||||
}
|
||||
}
|
||||
|
||||
// A typo in the vision endpoint, or a media block with no dir, used to be
|
||||
// logged once at wiring time and the capability just stayed off. A capability
|
||||
// that silently does not exist is the hardest misconfiguration to notice, so
|
||||
// both fail at startup now.
|
||||
func TestSensesBlocksAreValidatedAtStartup(t *testing.T) {
|
||||
bad := map[string]string{
|
||||
"media with no dir": `{"media":{"retention":"48h"}}`,
|
||||
"negative budget": `{"media":{"dir":"/srv/media","max_total_bytes":-1}}`,
|
||||
"blob over the budget": `{"media":{"dir":"/srv/media","max_bytes":100,"max_total_bytes":10}}`,
|
||||
"vision with no media dir": `{"vision":{"enabled":true,"endpoint":"http://127.0.0.1:8081"}}`,
|
||||
"vision endpoint typo": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"127.0.0.1:8081"}}`,
|
||||
"vision on the wan": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"http://8.8.8.8:8081"}}`,
|
||||
"vision, empty endpoint": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true}}`,
|
||||
"capture with no store": `{"capture":{"enabled":true}}`,
|
||||
}
|
||||
for name, body := range bad {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := Load(writeConfig(t, body)); err == nil {
|
||||
t.Fatal("want a startup error")
|
||||
}
|
||||
})
|
||||
}
|
||||
good := map[string]string{
|
||||
"media alone": `{"media":{"dir":"/srv/media"}}`,
|
||||
"media + vision": `{"media":{"dir":"/srv/media"},"vision":{"enabled":true,"endpoint":"http://127.0.0.1:8081"}}`,
|
||||
"media + capture": `{"media":{"dir":"/srv/media"},"capture":{"enabled":true}}`,
|
||||
"vision off": `{"vision":{"endpoint":"http://8.8.8.8:8081"}}`,
|
||||
}
|
||||
for name, body := range good {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := Load(writeConfig(t, body)); err != nil {
|
||||
t.Fatalf("valid config refused: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user