Compare commits

..

7 Commits

Author SHA1 Message Date
kami 7b507dec94 Merge pull request 'factEnrichmentWorker walks the pending queue twice per tick to write one log line' (#191) from task/647-factenrichmentworker-walks-the-pending-q into master 2026-08-06 22:33:54 +02:00
claude 2c0334c4fe Count the enrichment backlog without a second query (V-647)
`tick` read `PendingFactResolutions` at the scan limit, then `status`
read it again with the same limit for one log line. Up to 2000 rows per
tick on a database that serialises reads, to say how long the queue is.

`statusOf` counts over a batch the caller already holds, and the tick
passes it the batch it just read. A resolved fact leaves the queue, so
the loop collects what is still pending rather than reporting the
pre-tick count. `status(ctx)` stays as the querying form, for a caller
outside the tick with no batch in hand.

No behaviour change: the three counts still describe one row set, and
the same facts are attempted per tick.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:32:54 +04:00
kami 92cbdbfdd3 Merge pull request 'V-637 follow-up: telegram intake has no deploy switch, and the chat-id check cannot fail a boot' (#190) from task/646-v-637-follow-up-telegram-intake-has-no-d into master 2026-08-06 22:18:25 +02:00
claude e78b2d8992 the daemon table, against make build and compose (V-648)
The table listed nine binaries. make build builds eleven, and mavseal and
labelgen exist without targets. The running count said seven on homesrv;
docker-compose.yml runs five.

Adds mavgpud, mavupdate, mavseal and labelgen, and names why each absent daemon
is absent: mavmaild has no mail account, mavwaked and mavenclient belong on
workpc, and mavcaldav is an oversight (V-644).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:14:42 +04:00
claude 9d58922462 Refuse a telegram intake chat id the poller cannot match (V-646)
The push half accepts an @channelusername and the intake half cannot: an
inbound update names its chat by number, so an @-name matches nothing. The
check lived in NewPoller, which wireTelegramIntake logs and returns from, so a
box configured that way booted clean with a dead intake half and a working push
half. Nothing looked broken from the chat.

ValidateIntakeChatID moves the rule where config validation can reach it, the
same shape validateNetScan uses. It is stricter than the old prefix test: any
non-digit is refused, not just a leading @. An empty token or chat id still
means telegram is not wired, because an unset ${TELEGRAM_*} expands to empty
and that must not fail a box with no bot.

deploy/mavend.json turns intake on. The chat id on this box is numeric.

The onCallback comment claimed every path answers the callback. The fromOwner
early return does not, and silence toward a stranger is correct, so the comment
was what was wrong.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:14:42 +04:00
claude b5ac48c126 One boot path for the workers and the API (#189) 2026-08-06 21:54:13 +02:00
claude 69d0f5ee78 No deadline survives the turn path, from mavweb down to llama-server (#188)
Co-authored-by: claude <no-reply@agents.claude.kvmx.ru>
Co-committed-by: claude <no-reply@agents.claude.kvmx.ru>
2026-08-06 21:11:42 +02:00
11 changed files with 398 additions and 134 deletions
+21 -3
View File
@@ -53,7 +53,7 @@ CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored to
and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`:
```sh
make build # all 9 binaries
make build # all 11 binaries
make build-web # single daemon (pure-Go ones: web/waked/poll/caldav build without CGO)
make test # go test -race across ./internal/... ./cmd/... with CGO env set
```
@@ -82,13 +82,31 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test
| `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`, not this. |
| `mavcaldav` | CalDAV calendar sync. |
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
| `mavgpud` | GPU supervisor. **Runs on workpc, not homesrv** — own unit, `deploy/mavgpud.service`. Keeps llama-server loaded while the card is free (V-488). Maven never asks it for anything, it reads `/health` through `llm.Pair`. |
| `mavupdate` | Not a daemon. Operator CLI a human runs on the box to deploy a new build. |
Two more binaries have no Makefile target and are built with `go run` or `go build` when
they are needed. Neither is deployed.
| Binary | Role |
|---|---|
| `mavseal` | Recovery tool. Encrypts a live tmpfs working copy back to the ciphertext file when mavend was killed before `defer st.Close()` sealed it. |
| `labelgen` | Runs the stage 0 grammars over utterances and prints JSONL, the training data for the routing heads (V-546). |
Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/server wire
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
`deploy/telegram.env`) sets socket paths, model paths, and the phraser/embedder blocks.
**Seven of the nine run on homesrv. `mavwaked` and `mavenclient` do not, and that is the
decision, not an oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`).
**`docker-compose.yml` runs five: `mavend`, `mavsttd`, `mavttsd`, `mavweb`, `mavpoll`.**
Count against compose, not against the table. Four of the nine daemons are absent, and each
absence has a different reason.
`mavmaild` is commented out in compose, with the reason written beside it: it needs a mail
account and this box has none. `mavcaldav` appears nowhere at all, and unlike the other
three that is an oversight rather than a decision (V-644).
**`mavwaked` and `mavenclient` are absent by decision, not oversight** (Vikunja #463,
`docs/plans/17-where-the-voice-loop-runs.md`).
homesrv has a microphone — it is a laptop — but it is in the wrong room, so a wake-word
daemon there listens to nobody. They belong on a client machine where the owner is standing.
+120
View File
@@ -0,0 +1,120 @@
package main
import (
"context"
"errors"
"log"
"net"
"sync"
"time"
"github.com/kami/maven/internal/event"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/store"
)
// The two boot paths meet here. run() wires the daemon twice: once at boot
// when a key is in the environment, and once inside UnlockFn after a passkey
// assertion, minutes or days later. Listing the same wiring in both places is
// what let them drift — seven workers started untracked on the unlock path and
// two daemonAPI fields were never set there, silently, for as long as anyone
// had been cold-starting (V-639).
//
// So both paths call newDaemonAPI and startBackground and nothing else. A
// field or a worker added later reaches both paths or neither.
// bootDeps is everything the two constructors below read. It is filled from
// the same variables on both paths, by depsNow in run().
type bootDeps struct {
coreFor func() ipc.CoreAPI
tl *tickLoop
evBus *event.Bus
voiceW *voiceWiring
st *store.Store
factWorker *factEnrichmentWorker
evalWorker *memoryEvalWorker // nil ⇒ memory evaluation off (the default)
feedWkr *feedWorker // nil ⇒ no feed is read (the default)
crawlWkr *crawlWorker // nil ⇒ no page is watched (the default)
}
// newDaemonAPI builds the real CoreAPI, with every field set. The unlock path
// used to leave nexus and getMCPServers nil, so after a cold start
// ResolveEntity refused with a nexus block configured and /tools rendered
// "not configured" with an mcp block configured. Empty is a wrong answer
// there, not a degraded one.
func newDaemonAPI(d bootDeps) *daemonAPI {
api := &daemonAPI{
CoreAPI: d.coreFor(),
getTrace: d.tl.trace,
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return d.tl.morningStatus(ctx, time.Now()) },
getDayPlan: func(ctx context.Context) ipc.DayPlan { return d.tl.dayPlan(ctx, time.Now()) },
getEvents: intakeEventsFn(d.evBus),
getDecisions: turnDecisionsFn(d.voiceW),
seedStore: seedStoreIfAllowed(d.st),
nexus: nexusOf(d.voiceW),
}
if d.voiceW != nil && d.voiceW.handler != nil {
api.chatFn = d.voiceW.handler.handleText
// And the reverse: the handler was wired with the bare store adapter,
// which cannot serve the day plan. See upgradeAPI.
d.voiceW.handler.upgradeAPI(api)
}
if d.voiceW != nil && d.voiceW.mcp != nil {
api.getMCPServers = d.voiceW.mcp.status
}
return api
}
// namedWorker is one long-running goroutine. The name exists so the set is
// assertable from a test and readable in a log; nothing dispatches on it.
type namedWorker struct {
name string
run func(ctx context.Context)
}
// backgroundWorkers lists what this deployment runs. It is pure — it starts
// nothing — so a test can compare the set the two paths would start without
// standing a daemon up.
func backgroundWorkers(d bootDeps) []namedWorker {
var ws []namedWorker
if d.voiceW != nil && d.voiceW.server != nil {
ws = append(ws, namedWorker{"voice", func(context.Context) {
if err := d.voiceW.server.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
log.Printf("voice serve: %v", err)
}
}})
}
ws = append(ws,
namedWorker{"tick", d.tl.run},
namedWorker{"fact-enrichment", d.factWorker.run},
)
if d.evalWorker != nil {
ws = append(ws, namedWorker{"memory-eval", d.evalWorker.run})
}
if d.feedWkr != nil {
ws = append(ws, namedWorker{"feed", d.feedWkr.run})
}
if d.crawlWkr != nil {
ws = append(ws, namedWorker{"crawl", d.crawlWkr.run})
}
if d.voiceW != nil && d.voiceW.mcp != nil {
ws = append(ws, namedWorker{"mcp", d.voiceW.mcp.run})
}
if d.voiceW != nil && d.voiceW.home != nil {
ws = append(ws, namedWorker{"home", d.voiceW.home.run})
}
return ws
}
// startBackground starts every worker through goWorker, so waitWorkers can
// wait for it at shutdown. A worker started as a bare `go func()` is the
// shutdown bug documented at the end of run(): run() never returns, the
// deferred Close never seals the database, and the ciphertext goes stale.
func startBackground(ctx context.Context, wg *sync.WaitGroup, d bootDeps) {
for _, w := range backgroundWorkers(d) {
goWorker(wg, func() { w.run(ctx) })
}
if d.voiceW != nil && d.voiceW.server != nil {
log.Printf("mavend: voice listening on %s", d.voiceW.server.Addr())
}
}
+96
View File
@@ -0,0 +1,96 @@
package main
import (
"reflect"
"testing"
"github.com/kami/maven/internal/decision"
"github.com/kami/maven/internal/event"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/voice"
)
// fullDeps — a deployment with every optional piece present. Nothing here is
// run: newDaemonAPI takes method values and backgroundWorkers is pure, so
// zero-value wirings are enough to say what WOULD be started.
func fullDeps() bootDeps {
h := &reactiveHandler{
ecosystem: &ecosystemWiring{nexus: &nexusClient{}},
decisions: decision.NewRing(),
}
return bootDeps{
coreFor: func() ipc.CoreAPI { return ipc.UnimplementedCoreAPI{} },
tl: &tickLoop{},
evBus: event.NewBus(4),
st: &store.Store{},
factWorker: &factEnrichmentWorker{},
evalWorker: &memoryEvalWorker{},
feedWkr: &feedWorker{},
crawlWkr: &crawlWorker{},
voiceW: &voiceWiring{
server: &voice.Server{},
handler: h,
mcp: &mcpWiring{},
home: &homeWiring{},
},
}
}
// The unlock path used to build its own daemonAPI literal and leave nexus and
// getMCPServers nil (V-639). Both paths call newDaemonAPI now, so the drift
// that can still happen is a field added to the struct and not to the
// constructor. This catches that one, by name.
func TestNewDaemonAPISetsEveryField(t *testing.T) {
prev := allowSeedOnStart
allowSeedOnStart = true
defer func() { allowSeedOnStart = prev }()
api := newDaemonAPI(fullDeps())
v := reflect.ValueOf(*api)
for i := range v.NumField() {
if v.Field(i).IsZero() {
t.Errorf("newDaemonAPI left %s unset — a fully wired deployment must fill every field", v.Type().Field(i).Name)
}
}
}
// The handler is wired with the bare store adapter and cannot serve the day
// plan until upgradeAPI hands it the real one. The unlocked path did that and
// the unlock path did it too; keep it a property of the constructor.
func TestNewDaemonAPIUpgradesTheHandler(t *testing.T) {
d := fullDeps()
api := newDaemonAPI(d)
if d.voiceW.handler.api != ipc.CoreAPI(api) {
t.Fatal("newDaemonAPI did not hand the handler the API it built")
}
}
// Every worker the daemon runs goes through startBackground, so shutdown can
// wait for it. The unlock path used to start seven of these as bare
// `go func()` under a shadowed WaitGroup.
func TestBackgroundWorkersFullSet(t *testing.T) {
want := []string{"voice", "tick", "fact-enrichment", "memory-eval", "feed", "crawl", "mcp", "home"}
var got []string
for _, w := range backgroundWorkers(fullDeps()) {
got = append(got, w.name)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("workers = %v, want %v", got, want)
}
}
// A default box configures none of the optional blocks. Two workers always run
// and the rest stay dark, rather than a nil run being scheduled.
func TestBackgroundWorkersFloor(t *testing.T) {
d := fullDeps()
d.evalWorker, d.feedWkr, d.crawlWkr, d.voiceW = nil, nil, nil, nil
want := []string{"tick", "fact-enrichment"}
var got []string
for _, w := range backgroundWorkers(d) {
got = append(got, w.name)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("workers = %v, want %v", got, want)
}
}
+24 -8
View File
@@ -37,8 +37,8 @@ type factEnrichmentWorker struct {
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
// enrichmentScanLimit bounds how deep a single tick 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.
@@ -75,8 +75,8 @@ func newFactEnrichmentWorker(st *store.Store, eco *ecosystemWiring, interval tim
// 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
// All three numbers describe the same set of rows, whatever is still pending
// out of the first enrichmentScanLimit 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 {
@@ -86,13 +86,22 @@ type enrichmentStatus struct {
Scanned int // rows the other three counts were taken over
}
// status reads the queue and counts over it. For a caller with no batch in
// hand — anything asking the worker how it is doing from outside the tick.
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
return enrichmentStatus{}
}
return w.statusOf(pending)
}
// statusOf counts over a batch the caller already has. The batch is the query
// the tick already ran, so reporting the backlog costs no second read of the
// scan limit — up to a thousand rows, on a database that serialises them.
func (w *factEnrichmentWorker) statusOf(pending []store.Fact) enrichmentStatus {
var st enrichmentStatus
st.Pending = len(pending)
st.Scanned = len(pending)
w.mu.Lock()
@@ -144,17 +153,24 @@ func (w *factEnrichmentWorker) tick(ctx context.Context) {
}
w.forgetDeparted(pending)
skipped, failed, attempted := 0, 0, 0
// A resolved fact leaves the pending queue, so the batch in hand overstates
// the backlog by however many succeeded. Drop them here rather than
// re-reading the queue to find out.
remaining := make([]store.Fact, 0, len(pending))
for _, f := range pending {
if attempted >= w.batch {
break
remaining = append(remaining, f)
continue
}
if !w.due(f.ID) {
skipped++
remaining = append(remaining, f)
continue
}
attempted++
if !w.resolveOne(ctx, f) {
failed++
remaining = append(remaining, f)
}
}
if failed > 0 {
@@ -164,7 +180,7 @@ func (w *factEnrichmentWorker) tick(ctx context.Context) {
// 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 {
if st := w.statusOf(remaining); 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)
}
+24 -112
View File
@@ -252,6 +252,23 @@ func run(args []string) error {
// envelope per successful intake write.
coreFor := func() ipc.CoreAPI { return newIntakeAPI(ipc.NewStoreAPI(st), evBus, time.Now) }
// depsNow reads whatever the current path has wired. Both boot paths build
// the CoreAPI and start the workers from this one value, so neither can
// hold a field the other misses. See cmd/mavend/boot.go.
depsNow := func() bootDeps {
return bootDeps{
coreFor: coreFor,
tl: tl,
evBus: evBus,
voiceW: voiceW,
st: st,
factWorker: factWorker,
evalWorker: evalWorker,
feedWkr: feedWkr,
crawlWkr: crawlWkr,
}
}
if !locked {
rules = wireRules(cfg)
gatherer = wireGatherer(st, cfg, rules)
@@ -284,26 +301,7 @@ func run(args []string) error {
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
coreAPI = &daemonAPI{
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),
getDecisions: turnDecisionsFn(voiceW),
seedStore: seedStoreIfAllowed(st),
nexus: nexusOf(voiceW),
}
if voiceW != nil && voiceW.handler != nil {
api := coreAPI.(*daemonAPI)
api.chatFn = voiceW.handler.handleText
// And the reverse: the handler was wired with the bare store
// adapter, which cannot serve the day plan. See upgradeAPI.
voiceW.handler.upgradeAPI(api)
}
if voiceW != nil && voiceW.mcp != nil {
coreAPI.(*daemonAPI).getMCPServers = voiceW.mcp.status
}
coreAPI = newDaemonAPI(depsNow())
} 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
@@ -497,19 +495,7 @@ func run(args []string) error {
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
// Swap the CoreAPI from the locked placeholder to the real store adapter.
newAPI := &daemonAPI{
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),
getDecisions: turnDecisionsFn(voiceW),
seedStore: seedStoreIfAllowed(st),
}
if voiceW != nil && voiceW.handler != nil {
newAPI.chatFn = voiceW.handler.handleText
voiceW.handler.upgradeAPI(newAPI)
}
newAPI := newDaemonAPI(depsNow())
srv.SetAPI(newAPI)
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
wireMailIntake(srv, st, phr, cfg, evBus)
@@ -524,59 +510,10 @@ func run(args []string) error {
// block, so no wire path takes a voiceprint on a default box.
wireSpeaker(srv, st, cfg)
// Start voice server.
if voiceW != nil {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
if err := voiceW.server.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
log.Printf("voice serve: %v", err)
}
}()
log.Printf("mavend: voice listening on %s", voiceW.server.Addr())
}
// Start tick loop.
go func() {
tl.run(ctx)
}()
// Start fact-entity enrichment worker.
go func() {
factWorker.run(ctx)
}()
// Start background memory evaluation (nil unless configured).
if evalWorker != nil {
go func() {
evalWorker.run(ctx)
}()
}
// Start feed reading (nil unless configured).
if feedWkr != nil {
go func() {
feedWkr.run(ctx)
}()
}
// Start the watched-page crawls (nil unless configured).
if crawlWkr != nil {
go func() {
crawlWkr.run(ctx)
}()
}
// 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)
}
// The voice server and every background worker, on the outer wg
// so shutdown waits for them. This used to be nine bare
// `go func()` calls and a shadowed WaitGroup (V-639).
startBackground(ctx, &wg, depsNow())
dl.unlock(st)
log.Printf("mavend: unlocked via passkey assertion")
@@ -591,33 +528,8 @@ func run(args []string) error {
})
log.Printf("mavend: ipc listening on %s", srv.Path())
if !locked && voiceW != nil {
goWorker(&wg, func() {
if err := voiceW.server.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
log.Printf("voice serve: %v", err)
}
})
log.Printf("mavend: voice listening on %s", voiceW.server.Addr())
}
if !locked {
goWorker(&wg, func() { tl.run(ctx) })
goWorker(&wg, func() { factWorker.run(ctx) })
if evalWorker != nil {
goWorker(&wg, func() { evalWorker.run(ctx) })
}
if feedWkr != nil {
goWorker(&wg, func() { feedWkr.run(ctx) })
}
if crawlWkr != nil {
goWorker(&wg, func() { crawlWkr.run(ctx) })
}
if voiceW != nil && voiceW.mcp != nil {
goWorker(&wg, func() { voiceW.mcp.run(ctx) })
}
if voiceW != nil && voiceW.home != nil {
goWorker(&wg, func() { voiceW.home.run(ctx) })
}
startBackground(ctx, &wg, depsNow())
}
<-ctx.Done()
+9 -1
View File
@@ -37,7 +37,15 @@
"This needs a matching ufw rule or the container's SYN is dropped:",
" ufw allow from 192.168.240.0/20 to any port 10808 proto tcp"
],
"proxy": "socks5://192.168.240.1:10808"
"proxy": "socks5://192.168.240.1:10808",
"//intake": [
"Read the chat as well as write to it (V-637). The poller long-polls",
"getUpdates through the same relay and accepts chat_id as the only",
"sender. Deleting this key turns inbound off again.",
"chat_id must be numeric here or the daemon refuses to start: an inbound",
"update names its chat by number, so an @-name would match nothing."
],
"intake": true
},
"//workstation": [
+18 -1
View File
@@ -1,9 +1,26 @@
# The two boot paths have drifted
Last verified: 06-08-2026 @ 06c1cf2
Last verified: 06-08-2026 @ 69d0f5e
V-639. Reads with `docs/operations.md`.
## What landed
`cmd/mavend/boot.go`. `newDaemonAPI(deps)` builds the CoreAPI with every field
set, and `startBackground(ctx, &wg, deps)` starts the voice server and every
worker through `goWorker`. `backgroundWorkers(deps)` is the pure list behind it,
so a test can compare the set without standing a daemon up. Both paths in
`run()` now read `coreAPI = newDaemonAPI(depsNow())` and one
`startBackground(...)`, where `depsNow` reads whatever the current path wired.
The shadowed `wg` is gone. Four tests in `cmd/mavend/boot_test.go`. Every
`daemonAPI` field is set on a fully wired deployment. The handler gets the API
it was built with. The worker set is asserted by name, at the full set and at
the floor.
Still by hand: unlock a locked box by passkey, ask something that needs Nexus,
and check `/tools` lists the MCP servers.
## What is wrong
`run()` in `cmd/mavend/main.go` brings the daemon up two ways. A box with a key in the
+21
View File
@@ -456,9 +456,30 @@ func (c *Config) validate() error {
if err := c.validateCapture(); err != nil {
return err
}
if err := c.validateTelegram(); err != nil {
return err
}
return nil
}
// validateTelegram refuses an intake half that cannot read the chat it is
// pointed at. The push half accepts an @channelusername and the intake half
// does not, so a box configured with both boots clean, keeps pushing, and
// answers nothing — the failure is invisible from the chat. Same shape as
// validateNetScan: fail the config rather than the turn.
func (c *Config) validateTelegram() error {
if c.Telegram == nil || !c.Telegram.Intake {
return nil
}
// An unset ${TELEGRAM_*} expands to empty, and the daemon already reads an
// empty token or chat id as telegram not being wired at all. Validating a
// block that wires nothing would fail a box that merely has no bot.
if c.Telegram.BotToken == "" || c.Telegram.ChatID == "" {
return nil
}
return telegramsink.ValidateIntakeChatID(c.Telegram.ChatID)
}
// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins
// over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens
// a plaintext store. A configured-but-invalid key is an error (fail closed,
+26
View File
@@ -466,3 +466,29 @@ func TestNormaliseKeepsExplicitWorkstationHealth(t *testing.T) {
t.Errorf("Health = %q, want %q", got, want)
}
}
func TestTelegramIntakeRefusesNamedChat(t *testing.T) {
// The push half accepts an @channelusername and the intake half cannot use
// one, so a box with both boots clean and answers nothing. Refuse the
// config instead.
p := writeConfig(t, `{"telegram":{"bot_token":"t","chat_id":"@maven","intake":true}}`)
if _, err := Load(p); err == nil {
t.Fatal("Load succeeded for intake with an @-name chat id; want error")
}
}
func TestTelegramNamedChatOKWithoutIntake(t *testing.T) {
// Push-only is what the @-name is for, so nothing changes for a box that
// never turned intake on.
p := writeConfig(t, `{"telegram":{"bot_token":"t","chat_id":"@maven"}}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load: %v", err)
}
}
func TestTelegramIntakeAcceptsNumericChat(t *testing.T) {
p := writeConfig(t, `{"telegram":{"bot_token":"t","chat_id":"-1001234567890","intake":true}}`)
if _, err := Load(p); err != nil {
t.Fatalf("Load: %v", err)
}
}
+24 -9
View File
@@ -49,6 +49,24 @@ type Poller struct {
offset int64
}
// ValidateIntakeChatID refuses a chat id the intake half cannot use. The push
// half accepts @channelusername as a destination. The intake half cannot: an
// inbound update names its chat by numeric id, so an @-name would match nothing
// and the poller would read the chat and answer none of it. Config validation
// calls this, so the box refuses to boot rather than running a dead reach —
// NewPoller returning an error is too late, because the daemon is already up.
func ValidateIntakeChatID(chatID string) error {
id := strings.TrimSpace(chatID)
if id == "" {
return errors.New("telegramsink: intake needs a chat id")
}
digits := strings.TrimPrefix(id, "-")
if digits == "" || strings.TrimLeft(digits, "0123456789") != "" {
return fmt.Errorf("telegramsink: intake needs the numeric chat id, not %s", chatID)
}
return nil
}
// NewPoller builds the intake half around an already-validated sink, so the
// token, the base URL and the relay are resolved in one place. turn is
// required; correct may be nil, and then the reply carries no buttons.
@@ -59,12 +77,8 @@ func NewPoller(s *Sink, turn Turn, correct Correct) (*Poller, error) {
if turn == nil {
return nil, errors.New("telegramsink: intake needs a turn handler")
}
// The push half accepts @channelusername as a destination. The intake half
// cannot: an inbound update names its chat by numeric id, so an @-name would
// match nothing and the poller would read the chat and answer none of it.
// Refusing here is the difference between a boot error and a dead reach.
if strings.HasPrefix(strings.TrimSpace(s.cfg.ChatID), "@") {
return nil, fmt.Errorf("telegramsink: intake needs the numeric chat id, not %s", s.cfg.ChatID)
if err := ValidateIntakeChatID(s.cfg.ChatID); err != nil {
return nil, err
}
// The sink's transport already carries the relay. Only the timeout differs,
// and it has to clear the long poll.
@@ -164,9 +178,10 @@ func (p *Poller) onMessage(ctx context.Context, m *message) {
}
}
// onCallback handles a tap on a correction button. Every path answers the
// callback: telegram spins a clock on the button until it is answered, and an
// unanswered tap reads as a gesture that was dropped.
// onCallback handles a tap on a correction button. Every path from the owner
// answers the callback: telegram spins a clock on the button until it is
// answered, and an unanswered tap reads as a gesture that was dropped. A tap
// from anyone else gets silence, the same as a message from a stranger.
func (p *Poller) onCallback(ctx context.Context, cb *callbackQuery) {
if !p.fromOwner(cb.Message.Chat.idString()) {
return
@@ -199,3 +199,18 @@ func TestNewPollerNeedsATurn(t *testing.T) {
t.Error("built a poller with no sink to answer through")
}
}
// A chat id the intake half cannot match is refused before anything reads the
// chat. Config validation calls the same check, so this is the boot error.
func TestValidateIntakeChatID(t *testing.T) {
for _, ok := range []string{"123", "-1001234567890", " 42 "} {
if err := ValidateIntakeChatID(ok); err != nil {
t.Errorf("ValidateIntakeChatID(%q): %v", ok, err)
}
}
for _, bad := range []string{"", "@maven", "-", "12a", "1 2"} {
if err := ValidateIntakeChatID(bad); err == nil {
t.Errorf("ValidateIntakeChatID(%q) accepted; want error", bad)
}
}
}