Make delivery and integration failures explicit
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
This commit is contained in:
@@ -54,6 +54,10 @@ opencode.json
|
||||
# Test coverage output
|
||||
coverage.out
|
||||
|
||||
# Python service/test bytecode.
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Agent worktrees and local agent state. The workflow itself is tracked: the
|
||||
# hooks, the skills and the prose dictionary are how a session behaves, so they
|
||||
# get reviewed like code. Everything else under .claude/ is scratch.
|
||||
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
# Maven completion journal
|
||||
|
||||
This journal tracks the autonomous completion goal started on 2026-08-13. It
|
||||
is an operational index, not a substitute for living subsystem documentation,
|
||||
dated evaluations, Vikunja tasks, or focused caveat entries.
|
||||
|
||||
## 2026-08-13 — baseline and backlog reconstruction
|
||||
|
||||
Goal: make Maven usable end to end with every current and planned feature
|
||||
wired, tested, and polished. Completion requires clean automated gates and
|
||||
successful sessions across local, degraded-ecosystem, and integrated modes.
|
||||
|
||||
Initial observations:
|
||||
|
||||
- `HEAD` is `2cf8b7e`, identical to both local and remote `master`, while the
|
||||
checked-out branch is the stale `task/704-...` branch.
|
||||
- The worktree already contained staged documentation/evaluation changes,
|
||||
staged transcript deletions, an unstaged `deploy/mavwaked.service` change,
|
||||
and untracked `deploy/asoundrc`. These are pre-existing work and are being
|
||||
preserved and validated before any commit.
|
||||
- The repository has no prior goal journal. Durable subsystem facts continue
|
||||
to belong under `docs/`; unresolved limits continue to belong under
|
||||
`docs/caveats/` with a task and revisit trigger.
|
||||
|
||||
Work streams started:
|
||||
|
||||
- Vikunja project 2: inventory every open task and recover acceptance criteria.
|
||||
- Repository: compare feature plans, caveats, routing/ecosystem contracts, and
|
||||
implementation.
|
||||
- Verification: run build, race tests, simulator, analyzer gates, and inspect
|
||||
skipped hardware/model evaluations separately.
|
||||
- Runtime: exercise the web, IPC, voice, model, and sibling-service paths with
|
||||
real local dependencies where available and explicit degraded-mode probes
|
||||
otherwise.
|
||||
|
||||
References: `docs/workflow.md`, `docs/qa.md`, `docs/ecosystem.md`,
|
||||
`docs/routing.md`, `docs/caveats/CLAUDE.md`, and Vikunja Maven project 2.
|
||||
|
||||
### Backlog correction
|
||||
|
||||
The first Vikunja page was accidentally read without a `done: false` filter and
|
||||
mixed closed history into the working set. Re-querying all pages strictly open
|
||||
produced 127 records: 89 implementation-open, 20 shipped-but-QA-open, 12
|
||||
external/owner gates, and 6 duplicate or stale-open records. Closed tasks are
|
||||
used only as commit provenance; they are not work to redo. V-704 was the only
|
||||
open hanging task and was closed after its measured correction landed.
|
||||
|
||||
### Model-aware baseline
|
||||
|
||||
The ordinary `make test` passed but does not set `MAVEN_ONNX_LIB`, so model-aware
|
||||
tests can self-skip. The explicit ONNX boundary gate exposed V-702/V-703: the
|
||||
held-out `я рассказывал тебе про байкал?` was the sole miss at 28/29. A
|
||||
three-neighbour class score fixed the semantic collision without adding a word
|
||||
pattern or copying the held-out sentence. Boundary is now 29/29 and the adjacent
|
||||
topic gate remains 43/43. Measurement:
|
||||
`docs/evals/2026-08-13-personal-boundary-neighbourhood.md`.
|
||||
|
||||
That narrow result was not accepted as the completion gate. A second agent
|
||||
wrote a balanced 72-case RU/EN matrix across remembered speech, possession,
|
||||
narrative, proper nouns, personal preambles, and advice/current questions. It
|
||||
contains no production seeds and no Baikal paraphrase. The top-three candidate
|
||||
scores only 61/72 (84.7%); top-two reaches 62/72, one-neighbour 56/72, and a
|
||||
whole-class centroid 54/72. V-702 therefore remains open while a principled
|
||||
classifier is developed against the independent matrix. The 29/29 measurement
|
||||
describes the narrow regression set, not general boundary quality.
|
||||
|
||||
### Live delivery incident
|
||||
|
||||
The five-service compose stack was running, but a due reminder was being
|
||||
re-phrased and retried through ntfy every tick. The sink returned HTTP 403 each
|
||||
time. Only secret names were inspected: the configured ntfy and workstation
|
||||
token variables were absent from the deployed environment file; no secret
|
||||
values were read into this journal. The durable outbox records each failed
|
||||
attempt, but the retry path has no backoff or alternate channel and spends the
|
||||
resident model again before every failure. This is active V-651 behavior, with
|
||||
the repeated-phrasing shape related to V-687.
|
||||
|
||||
V-715 now owns the incident acceptance criteria. In the working tree, phrases,
|
||||
collapsed-group identity, attempts, and next-attempt time are durable; definite
|
||||
failure backs off from one minute to a capped hour; retries and restarts reuse
|
||||
the exact phrase; and away delivery tries ntfy then Telegram, stopping at the
|
||||
first success. The committed deployment explicitly disables the uncredentialed
|
||||
ntfy block. Independent review added a real occurrence key for collapsed
|
||||
bundles, suppresses crash-ambiguous attempts from automatic replay, classifies
|
||||
HTTP 401/403 as permanent, blocks permanently unreachable reminders visibly,
|
||||
and commits the successful outbox result plus every collapsed original in one
|
||||
SQLite transaction. The store, delivery, IPC, loop, config, and mavweb race
|
||||
suites pass. A live rebuild and one-time delivery of the existing backlog are
|
||||
still required before V-715 is closed.
|
||||
|
||||
### Explicit integration enablement
|
||||
|
||||
V-691's deployment boundary was audited against every `${VAR}` reference. The
|
||||
canonical `deploy/telegram.env.example` now names Telegram, ntfy, workstation
|
||||
model, workstation STT, Home Assistant, ambient, CW2, and database-key inputs.
|
||||
Enabled Telegram, ntfy, ambient, non-loopback workstation model, and
|
||||
non-loopback workstation STT paths refuse missing credentials; each arm has an
|
||||
explicit disabled state. The live config disables the currently uncredentialed
|
||||
ntfy and workstation-model arms while retaining the separately credentialed
|
||||
STT arm. CW2 also refuses a non-loopback bind without its token.
|
||||
|
||||
Focused Go race suites, the Python CW2 startup contract, deploy-config drift
|
||||
test, secret-expanded config validation (values not printed), and
|
||||
`docker compose config --quiet` pass. The workpc is unreachable from this host,
|
||||
so installing the updated CW2 script there remains an external deployment step;
|
||||
the affected model arm is explicitly dark rather than ambiguously half-live.
|
||||
|
||||
### Traceable web failures
|
||||
|
||||
V-689 gives every mavweb response a server-generated request ID and routes
|
||||
every handler failure through one sanitized problem envelope. Stable error
|
||||
codes and the request ID reach the browser; the wrapped internal error reaches
|
||||
only the server log beside the same ID. Degraded inline panels use stable public
|
||||
text rather than backend paths or tokens, and direct ecosystem reads propagate
|
||||
the web request ID as their correlation ID. An AST guard prevents new production
|
||||
handlers from bypassing the contract with `http.Error`. The full mavweb race
|
||||
suite passes, including disclosure, untrusted-ID, log-join, and propagation
|
||||
tests.
|
||||
|
||||
### Bounded external responses
|
||||
|
||||
Three audit defects were repaired and committed directly to `master`:
|
||||
|
||||
- V-608 (`d7e8804`): llama completion responses are capped at 1 MiB, including
|
||||
the LAN workstation seam.
|
||||
- V-675 (`459fe7a`): remote STT requires nonblank text and an explicit finite
|
||||
confidence in `[0,1]`, caps JSON at 64 KiB, and falls back to mavsttd on a
|
||||
malformed HTTP 200.
|
||||
- V-676 (`7d0250a`): Open-Meteo geocoding and forecasts are bounded, required
|
||||
fields are nullable/validated, and coordinates/weather values are range
|
||||
checked so `{}` cannot become plausible zero-degree weather.
|
||||
|
||||
Each focused race suite passed and each task was closed only after the commit.
|
||||
|
||||
### Transport shutdown
|
||||
|
||||
V-679 (`de61b75`) adds the listener's `done` channel to TCP `Accept`. A
|
||||
concurrent-close test holds a silent peer in handshake and proves an in-flight
|
||||
accept returns `net.ErrClosed`; the race test passed twenty consecutive runs.
|
||||
|
||||
V-688 (`80b6068`, caveat retirement `a0e6643`) bounds the browser push-to-talk
|
||||
body at ten minutes of mono PCM and configures header, idle, and read limits on
|
||||
the web server. The unused `/ws` handler was removed instead of retaining a
|
||||
second unauthenticated streaming transport with no browser caller. Focused race
|
||||
tests prove the exact-size request succeeds and an oversized request returns
|
||||
HTTP 413.
|
||||
|
||||
### Conversation continuity
|
||||
|
||||
V-542 (`da9114b`) repairs the five-turn monitor conversation without changing
|
||||
single-turn intent classification. Exact user utterances are now persisted
|
||||
separately from normalized intent slots and retained in chronological order.
|
||||
An anaphoric query with live transcript context reaches the chat path, while
|
||||
non-anaphoric sources are unchanged and acts stay fail-closed. An explicit
|
||||
conversation opener extends the session lifetime through later fact/query/chat
|
||||
routes without suppressing the grounded fact write.
|
||||
|
||||
The deterministic scenario now names the monitor in all four contextual
|
||||
replies, proves that the original raw turn reached `PhraseChat` four times,
|
||||
stores the fact once, and produces zero unsolicited sends across five turns and
|
||||
one tick. Focused race tests passed for `cmd/mavend`, `internal/dialogue`,
|
||||
`internal/router`, and `internal/lexicon`. Measurement:
|
||||
`docs/evals/2026-08-13-conversation-continuity.md`.
|
||||
|
||||
### Personal-data boundary
|
||||
|
||||
V-702 replaces the narrow nearest-neighbour privacy gate with a frozen,
|
||||
class-balanced logistic head over multilingual-e5-small. It introduces no
|
||||
lexical exception and leaves the decision threshold at 0.5. Historical
|
||||
regressions score 29/29 and the balanced 72-case RU/EN fixture scores 72/72.
|
||||
|
||||
The first 24-case challenge found one private-configuration miss. That result
|
||||
was treated as model-selection data rather than advertised as independent
|
||||
proof. Shrinkage LDA and an LDA/logistic ensemble repaired it but regressed the
|
||||
72-case gate, so both were rejected. Increasing the logistic L2 coefficient
|
||||
from 0.0001 to 0.0003 repairs the miss while improving four-fold corpus
|
||||
cross-validation from 97/104 to 99/104 and whole-shape holdout from 91/104 to
|
||||
92/104. A fresh 24-case challenge written only after that head was frozen scores
|
||||
24/24 at minimum signed probability margin +0.1718. The original challenge is
|
||||
also 24/24 but its +0.0001 edge remains documented as a regression, not fresh
|
||||
evidence. Full measurement:
|
||||
`docs/evals/2026-08-13-personal-boundary-linear-head.md`.
|
||||
|
||||
During the audit, running multiple ONNX-backed tests in one `go test` process
|
||||
showed that only the first initializes; later tests self-skip because the
|
||||
runtime is process-global. All V-702 figures were therefore rerun in separate
|
||||
processes. V-716 tracks fixing that harness gap rather than hiding it in this
|
||||
feature.
|
||||
@@ -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: t audit 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 deps-sentinel deps-vuln vuln deps-lint lint deadcode analyze tidy eval-router eval-reach eval-recall eval-phrasing eval-models build-gpud
|
||||
.PHONY: t audit simulate stt-fixtures test-stt-golden test-cw2-config 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 deps-sentinel deps-vuln vuln deps-lint lint deadcode analyze tidy eval-router eval-reach eval-recall eval-phrasing eval-models build-gpud
|
||||
|
||||
all: build
|
||||
|
||||
@@ -188,7 +188,10 @@ 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
|
||||
test-cw2-config:
|
||||
python3 -m unittest discover -s deploy/cw2 -p 'test_*.py'
|
||||
|
||||
test: fmt-check vet test-cw2-config
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
$(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/...
|
||||
|
||||
|
||||
+20
-8
@@ -559,7 +559,7 @@ func run(args []string) error {
|
||||
func personaFacts(cfg *config.Config) persona.Facts {
|
||||
f := persona.Facts{
|
||||
// Telegram lives outside the voice block, so it counts either way.
|
||||
Telegram: cfg.Telegram != nil && cfg.Telegram.BotToken != "" && cfg.Telegram.ChatID != "",
|
||||
Telegram: cfg.Telegram != nil && !cfg.Telegram.Disabled && cfg.Telegram.BotToken != "" && cfg.Telegram.ChatID != "",
|
||||
}
|
||||
if cfg.Voice == nil {
|
||||
return f
|
||||
@@ -678,16 +678,12 @@ func wireGatherer(st *store.Store, cfg *config.Config, rules []loop.Rule) *loop.
|
||||
// reconciled to "unknown" here, before the tick loop resumes sending, so
|
||||
// nothing auto-resends into that ambiguity.
|
||||
func wireDispatcher(st *store.Store, cfg *config.Config, voiceW *voiceWiring) (*delivery.Dispatcher, error) {
|
||||
var ntfy delivery.Sink
|
||||
if cfg.Ntfy != nil {
|
||||
s, err := ntfysink.New(*cfg.Ntfy)
|
||||
ntfy, err := wireNtfySink(cfg.Ntfy)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wire ntfy sink: %w", err)
|
||||
}
|
||||
ntfy = s
|
||||
return nil, err
|
||||
}
|
||||
var telegram delivery.Sink
|
||||
if cfg.Telegram != nil {
|
||||
if cfg.Telegram != nil && !cfg.Telegram.Disabled {
|
||||
s, err := telegramsink.New(*cfg.Telegram)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wire telegram sink: %w", err)
|
||||
@@ -712,6 +708,22 @@ func wireDispatcher(st *store.Store, cfg *config.Config, voiceW *voiceWiring) (*
|
||||
}), nil
|
||||
}
|
||||
|
||||
// wireNtfySink keeps an optional reach optional without ever turning a missing
|
||||
// secret into anonymous publishing. A block is live unless it says disabled;
|
||||
// therefore an expanded-empty token in a live block fails startup instead of
|
||||
// spending days in a permanent 403 retry loop. Disabled is an explicit
|
||||
// operator choice and lets another away reach take over.
|
||||
func wireNtfySink(cfg *ntfysink.Config) (delivery.Sink, error) {
|
||||
if cfg == nil || cfg.Disabled {
|
||||
return nil, nil
|
||||
}
|
||||
sink, err := ntfysink.New(*cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wire ntfy sink: %w", err)
|
||||
}
|
||||
return sink, nil
|
||||
}
|
||||
|
||||
// wireTickLoop reads the loop's three intervals and its schedules out of the
|
||||
// config, so the two boot paths cannot disagree about them.
|
||||
func wireTickLoop(st *store.Store, gatherer *loop.Gatherer, dispatcher *delivery.Dispatcher, phr phraser.Phraser, rules []loop.Rule, cfg *config.Config) *tickLoop {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/delivery/ntfysink"
|
||||
)
|
||||
|
||||
func TestWireNtfySinkRejectsMissingCredentialWhenEnabled(t *testing.T) {
|
||||
_, err := wireNtfySink(&ntfysink.Config{
|
||||
BaseURL: "https://ntfy.example", Topic: "maven",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expanded-empty credential did not fail an enabled reach")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireNtfySinkLeavesExplicitlyDisabledReachDark(t *testing.T) {
|
||||
sink, err := wireNtfySink(&ntfysink.Config{
|
||||
Disabled: true, BaseURL: "https://ntfy.example", Topic: "maven",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("wireNtfySink: %v", err)
|
||||
}
|
||||
if sink != nil {
|
||||
t.Fatal("disabled reach built a live sink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireNtfySinkRejectsMalformedEnabledConfig(t *testing.T) {
|
||||
_, err := wireNtfySink(&ntfysink.Config{Token: "token", Topic: "maven"})
|
||||
if err == nil {
|
||||
t.Fatal("malformed enabled config did not fail wiring")
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
// already failed the boot in wireDispatcher for the same config, so a second
|
||||
// hard failure would only lose that message.
|
||||
func wireTelegramIntake(ctx context.Context, wg *sync.WaitGroup, api ipc.CoreAPI, cfg *config.Config) {
|
||||
if cfg == nil || cfg.Telegram == nil || !cfg.Telegram.Intake || api == nil {
|
||||
if cfg == nil || cfg.Telegram == nil || cfg.Telegram.Disabled || !cfg.Telegram.Intake || api == nil {
|
||||
return
|
||||
}
|
||||
sink, err := telegramsink.New(*cfg.Telegram)
|
||||
|
||||
+125
-11
@@ -10,11 +10,13 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -226,18 +228,11 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
// detectPatterns below for how idempotence and dismissal are respected.
|
||||
t.detectPatterns(ctx, now, state)
|
||||
|
||||
// reminders: gate-bypassing class. fired once, marked after a successful
|
||||
// delivery. a failed send leaves the reminder pending — the next tick
|
||||
// re-gathers and re-attempts.
|
||||
// reminders: gate-bypassing class. The presentation and retry clock live on
|
||||
// the reminder occurrence, so a transport outage neither spends the model
|
||||
// every tick nor changes what the reminder says after a restart.
|
||||
for _, d := range loop.RemindDecisions(state, due) {
|
||||
pr, err := t.phraser.PhraseReminder(ctx, d)
|
||||
if err != nil {
|
||||
log.Printf("tick: phrase reminder %d: %v", d.Reminder.ID, err)
|
||||
continue
|
||||
}
|
||||
if _, err := t.dispatcher.DispatchReminder(ctx, pr, now); err != nil {
|
||||
log.Printf("tick: dispatch reminder %d: %v", d.Reminder.ID, err)
|
||||
}
|
||||
t.deliverReminder(ctx, d, now)
|
||||
}
|
||||
|
||||
// sev4-away repeats: re-send un-acked telegram nudges per repeatInterval.
|
||||
@@ -263,6 +258,125 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// deliverReminder advances one due reminder (or collapsed bundle) through the
|
||||
// durable delivery state. A phrase is cached before the first external send;
|
||||
// every definite failure advances the persisted bounded backoff.
|
||||
func (t *tickLoop) deliverReminder(ctx context.Context, d loop.ReminderDecision, now time.Time) {
|
||||
originals := reminderOriginals(d.Reminder)
|
||||
pr, cached := cachedReminderPhrase(d, originals)
|
||||
if !cached {
|
||||
var err error
|
||||
pr, err = t.phraser.PhraseReminder(ctx, d)
|
||||
if err == nil && pr.Body == "" {
|
||||
err = errors.New("phraser returned an empty reminder body")
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("tick: phrase reminder %d: %v", d.Reminder.ID, err)
|
||||
t.scheduleReminderRetry(ctx, originals, now)
|
||||
return
|
||||
}
|
||||
if pr.Mood == "" {
|
||||
pr.Mood = "neutral"
|
||||
}
|
||||
group := reminderDeliveryGroup(originals)
|
||||
if err := t.store.CacheReminderPhrase(
|
||||
ctx, originals, group, pr.Body, pr.Summary, pr.Mood,
|
||||
); err != nil {
|
||||
// A cancellation or another completion can win while phrasing. Do
|
||||
// not send a presentation that no longer owns every original.
|
||||
log.Printf("tick: cache reminder %d phrase: %v", d.Reminder.ID, err)
|
||||
return
|
||||
}
|
||||
// The store now owns the phrase, but this tick's value predates that
|
||||
// write. Stamp the exact persisted occurrence identity onto the value
|
||||
// handed to the dispatcher so its outbox row can suppress an ambiguous
|
||||
// crash for both a real reminder and a synthetic collapsed bundle.
|
||||
for i := range originals {
|
||||
originals[i].DeliveryGroup = group
|
||||
originals[i].PhraseBody = pr.Body
|
||||
originals[i].PhraseSummary = pr.Summary
|
||||
originals[i].PhraseMood = pr.Mood
|
||||
}
|
||||
if d.Reminder.ID == 0 {
|
||||
d.Reminder.Collapsed = originals
|
||||
} else {
|
||||
d.Reminder = originals[0]
|
||||
}
|
||||
}
|
||||
// A phraser is not allowed to substitute the reminder decision. In
|
||||
// particular, the durable group stamped above must reach the outbox.
|
||||
pr.Decision = d
|
||||
|
||||
if _, err := t.dispatcher.DispatchReminder(ctx, pr, now); err != nil {
|
||||
log.Printf("tick: dispatch reminder %d: %v", d.Reminder.ID, err)
|
||||
t.scheduleReminderRetry(ctx, originals, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tickLoop) scheduleReminderRetry(ctx context.Context, originals []store.Reminder, now time.Time) {
|
||||
if err := t.store.ScheduleReminderRetry(ctx, originals, now); err != nil {
|
||||
log.Printf("tick: schedule reminder retry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// reminderOriginals converts the synthetic ID=0 bundle back to real store
|
||||
// rows. Keeping this in one helper makes it impossible to accidentally persist
|
||||
// retry state against reminder zero.
|
||||
func reminderOriginals(r store.Reminder) []store.Reminder {
|
||||
if r.ID == 0 {
|
||||
return append([]store.Reminder(nil), r.Collapsed...)
|
||||
}
|
||||
return []store.Reminder{r}
|
||||
}
|
||||
|
||||
// cachedReminderPhrase reconstructs a PhrasedReminder only when every original
|
||||
// agrees on one persisted group and presentation. That agreement is what lets
|
||||
// a collapsed bundle survive a restart without being re-phrased.
|
||||
func cachedReminderPhrase(d loop.ReminderDecision, originals []store.Reminder) (delivery.PhrasedReminder, bool) {
|
||||
if len(originals) == 0 || !originals[0].HasDeliveryPhrase() {
|
||||
return delivery.PhrasedReminder{}, false
|
||||
}
|
||||
first := originals[0]
|
||||
for _, r := range originals[1:] {
|
||||
if !r.HasDeliveryPhrase() ||
|
||||
r.DeliveryGroup != first.DeliveryGroup ||
|
||||
r.PhraseBody != first.PhraseBody ||
|
||||
r.PhraseSummary != first.PhraseSummary ||
|
||||
r.PhraseMood != first.PhraseMood {
|
||||
return delivery.PhrasedReminder{}, false
|
||||
}
|
||||
}
|
||||
mood := first.PhraseMood
|
||||
if mood == "" {
|
||||
mood = "neutral"
|
||||
}
|
||||
return delivery.PhrasedReminder{
|
||||
Decision: d,
|
||||
Body: first.PhraseBody,
|
||||
Summary: first.PhraseSummary,
|
||||
Mood: mood,
|
||||
}, true
|
||||
}
|
||||
|
||||
// reminderDeliveryGroup deterministically names one occurrence or collapsed
|
||||
// set. The next-fire instant is part of the identity so a recurring reminder's
|
||||
// later occurrence can never inherit the previous occurrence's phrase.
|
||||
func reminderDeliveryGroup(originals []store.Reminder) string {
|
||||
ordered := append([]store.Reminder(nil), originals...)
|
||||
sort.Slice(ordered, func(i, j int) bool {
|
||||
if ordered[i].ID == ordered[j].ID {
|
||||
return ordered[i].NextFireTs.Before(ordered[j].NextFireTs)
|
||||
}
|
||||
return ordered[i].ID < ordered[j].ID
|
||||
})
|
||||
h := sha256.New()
|
||||
for _, r := range ordered {
|
||||
_, _ = fmt.Fprintf(h, "%d:%d;", r.ID, r.NextFireTs.UnixMilli())
|
||||
}
|
||||
sum := h.Sum(nil)
|
||||
return fmt.Sprintf("reminder:%x", sum[:12])
|
||||
}
|
||||
|
||||
// savePresence writes back the bucket GatherState just resolved.
|
||||
//
|
||||
// It lives here and not in GatherState because that method holds a read-only
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
type reminderCountingPhraser struct {
|
||||
phraser.Phraser
|
||||
calls int
|
||||
body string
|
||||
summary string
|
||||
mood string
|
||||
}
|
||||
|
||||
func (p *reminderCountingPhraser) PhraseReminder(_ context.Context, d loop.ReminderDecision) (delivery.PhrasedReminder, error) {
|
||||
p.calls++
|
||||
return delivery.PhrasedReminder{
|
||||
Decision: d,
|
||||
Body: p.body,
|
||||
Summary: p.summary,
|
||||
Mood: p.mood,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type reminderFailSink struct {
|
||||
sends int
|
||||
}
|
||||
|
||||
func (s *reminderFailSink) Send(_ context.Context, _ delivery.Sendable) error {
|
||||
s.sends++
|
||||
return errors.New("transport unavailable")
|
||||
}
|
||||
|
||||
func newReminderDeliveryLoop(t *testing.T, st *store.Store, sink delivery.Sink, p phraser.Phraser) *tickLoop {
|
||||
t.Helper()
|
||||
rules := loop.DefaultRules()
|
||||
return newTickLoop(
|
||||
st,
|
||||
loop.NewGatherer(st, rules),
|
||||
delivery.NewDispatcher(delivery.Config{
|
||||
Voice: sink, Ntfy: sink, Telegram: sink,
|
||||
Nudges: st, Reminders: st, Outbox: st,
|
||||
}),
|
||||
p,
|
||||
rules,
|
||||
time.Second,
|
||||
5*time.Minute,
|
||||
0,
|
||||
nil, nil, nil, nil,
|
||||
)
|
||||
}
|
||||
|
||||
func TestTickReminderRetryUsesPersistedPhraseAfterRestart(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
if _, err := st.CreateReminder(ctx, now.Add(-time.Minute), `{"text":"позвонить маме"}`, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fail := &reminderFailSink{}
|
||||
firstPhraser := &reminderCountingPhraser{
|
||||
Phraser: phraser.NewStub(), body: "Не забудь позвонить маме.",
|
||||
summary: "Позвонить маме", mood: "warm",
|
||||
}
|
||||
tl := newReminderDeliveryLoop(t, st, fail, firstPhraser)
|
||||
tl.tick(ctx, now)
|
||||
if firstPhraser.calls != 1 {
|
||||
t.Fatalf("first tick phrased %d times, want 1", firstPhraser.calls)
|
||||
}
|
||||
rows, err := st.ListReminders(ctx, 1)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("list = %d, err=%v", len(rows), err)
|
||||
}
|
||||
if !rows[0].HasDeliveryPhrase() || rows[0].DeliveryAttempts != 1 {
|
||||
t.Fatalf("failed delivery state was not persisted: %+v", rows[0])
|
||||
}
|
||||
if want := now.Add(store.ReminderRetryBase); !rows[0].NextAttemptTs.Equal(want) {
|
||||
t.Fatalf("next attempt = %s, want %s", rows[0].NextAttemptTs, want)
|
||||
}
|
||||
|
||||
// A normal tick inside the wait does no transport work and no model work.
|
||||
sendsAfterFirst := fail.sends
|
||||
tl.tick(ctx, now.Add(30*time.Second))
|
||||
if firstPhraser.calls != 1 || fail.sends != sendsAfterFirst {
|
||||
t.Fatalf("retry wait did work: phrase calls=%d, sends=%d (was %d)", firstPhraser.calls, fail.sends, sendsAfterFirst)
|
||||
}
|
||||
|
||||
// Constructing a new loop is the daemon-restart boundary. Its phraser would
|
||||
// say something different if called; the stored phrase must win instead.
|
||||
success := &fakeSink{}
|
||||
afterRestart := &reminderCountingPhraser{
|
||||
Phraser: phraser.NewStub(), body: "WRONG NEW PHRASE", summary: "WRONG", mood: "neutral",
|
||||
}
|
||||
restarted := newReminderDeliveryLoop(t, st, success, afterRestart)
|
||||
restarted.tick(ctx, now.Add(store.ReminderRetryBase))
|
||||
if afterRestart.calls != 0 {
|
||||
t.Fatalf("restart re-phrased the reminder %d times", afterRestart.calls)
|
||||
}
|
||||
if len(success.sends) != 1 {
|
||||
t.Fatalf("retry sends = %d, want 1", len(success.sends))
|
||||
}
|
||||
if got := success.sends[0].Body; got != "Позвонить маме" {
|
||||
t.Fatalf("away retry body = %q, want persisted summary", got)
|
||||
}
|
||||
rows, err = st.ListReminders(ctx, 1)
|
||||
if err != nil || rows[0].Status != store.ReminderFired {
|
||||
t.Fatalf("successful retry did not fire reminder: rows=%+v err=%v", rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTickCollapsedReminderRetriesOnePhraseAndCompletesOriginals(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
for _, text := range []string{"полить цветы", "записаться к врачу"} {
|
||||
if _, err := st.CreateReminder(ctx, now.Add(-time.Minute), text, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
fail := &reminderFailSink{}
|
||||
firstPhraser := &reminderCountingPhraser{
|
||||
Phraser: phraser.NewStub(), body: "У тебя два напоминания.",
|
||||
summary: "Два напоминания", mood: "neutral",
|
||||
}
|
||||
newReminderDeliveryLoop(t, st, fail, firstPhraser).tick(ctx, now)
|
||||
if firstPhraser.calls != 1 {
|
||||
t.Fatalf("collapsed bundle phrased %d times, want 1", firstPhraser.calls)
|
||||
}
|
||||
rows, err := st.ListReminders(ctx, 10)
|
||||
if err != nil || len(rows) != 2 {
|
||||
t.Fatalf("list = %d, err=%v", len(rows), err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.DeliveryGroup == "" || r.DeliveryGroup != rows[0].DeliveryGroup ||
|
||||
r.PhraseBody != "У тебя два напоминания." || r.DeliveryAttempts != 1 {
|
||||
t.Fatalf("collapsed original lost shared state: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
success := &fakeSink{}
|
||||
afterRestart := &reminderCountingPhraser{
|
||||
Phraser: phraser.NewStub(), body: "WRONG", summary: "WRONG", mood: "neutral",
|
||||
}
|
||||
newReminderDeliveryLoop(t, st, success, afterRestart).tick(ctx, now.Add(store.ReminderRetryBase))
|
||||
if afterRestart.calls != 0 {
|
||||
t.Fatalf("collapsed retry re-phrased %d times", afterRestart.calls)
|
||||
}
|
||||
if len(success.sends) != 1 || success.sends[0].ReminderID != 0 {
|
||||
t.Fatalf("collapsed retry sends = %+v, want one synthetic delivery", success.sends)
|
||||
}
|
||||
rows, err = st.ListReminders(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Status != store.ReminderFired {
|
||||
t.Fatalf("collapsed original %d status = %q, want fired", r.ID, r.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,19 +377,19 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
// degraded mode, so the seam is nil and the cascade routes with the classifier.
|
||||
func modelSeam(cfg *config.Config, resident *llm.Client) (router.Completer, *llm.Pair) {
|
||||
if resident == nil {
|
||||
if cfg.Workstation != nil {
|
||||
if cfg.Workstation != nil && !cfg.Workstation.ModelDisabled {
|
||||
log.Printf("voice: a workstation is configured but there is no resident model to floor it with — ignoring the block")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if cfg.Workstation == nil {
|
||||
if cfg.Workstation == nil || cfg.Workstation.ModelDisabled {
|
||||
return resident, nil
|
||||
}
|
||||
ws := cfg.Workstation
|
||||
remote := llm.New(ws.URL, time.Duration(ws.Timeout))
|
||||
remote.SetToken(ws.Token)
|
||||
if ws.Token == "" {
|
||||
log.Printf("voice: no workstation.token — mavgpud refuses an unauthenticated request, so this reads as a card that is always busy")
|
||||
log.Printf("voice: unauthenticated workstation model endpoint is loopback-only")
|
||||
}
|
||||
pair := llm.NewPair(
|
||||
remote,
|
||||
@@ -434,7 +434,7 @@ func sttSeam(cfg *config.Config, floor stt.Transcriber) (stt.Transcriber, *stt.P
|
||||
)
|
||||
pair.Start(context.Background())
|
||||
if s.Token == "" {
|
||||
log.Print("voice: the workstation transcriber has no token, so anything on the LAN can post audio to it")
|
||||
log.Print("voice: unauthenticated workstation transcriber endpoint is loopback-only")
|
||||
}
|
||||
log.Printf("voice: workstation transcriber at %s, probed every %s, mavsttd as the floor",
|
||||
s.URL, time.Duration(s.Probe))
|
||||
|
||||
+19
-11
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -51,34 +52,41 @@ type ambientResp struct {
|
||||
// never registered, so it is treated as a hard failure here too.
|
||||
func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, token string) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
||||
"POST only", nil)
|
||||
return
|
||||
}
|
||||
if token == "" {
|
||||
http.Error(w, "ambient ingest disabled (no -ambient-token)", http.StatusServiceUnavailable)
|
||||
writeProblem(w, r, http.StatusServiceUnavailable, problemIntegrationOff,
|
||||
"ambient ingest disabled (no -ambient-token)", nil)
|
||||
return
|
||||
}
|
||||
if !ambientAuthorized(r, token) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
writeProblem(w, r, http.StatusUnauthorized, problemUnauthorized,
|
||||
"unauthorized", nil)
|
||||
return
|
||||
}
|
||||
if core == nil {
|
||||
http.Error(w, "ambient ingest disabled (no -core)", http.StatusServiceUnavailable)
|
||||
writeProblem(w, r, http.StatusServiceUnavailable, problemCoreUnavailable,
|
||||
"ambient ingest disabled (no -core)", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var n calendar.Notification
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, ambientMaxBody))
|
||||
if err != nil {
|
||||
http.Error(w, "read failed", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"read failed", fmt.Errorf("read ambient request: %w", err))
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(body, &n); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"bad json", fmt.Errorf("decode ambient request: %w", err))
|
||||
return
|
||||
}
|
||||
if n.Posted.IsZero() {
|
||||
writeAmbient(w, http.StatusBadRequest, ambientResp{Reason: "posted_at is required"})
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"posted_at is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -99,8 +107,8 @@ func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, tok
|
||||
writeAmbient(w, http.StatusOK, ambientResp{Stored: false, Key: key, Reason: "unchanged"})
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, ipc.ErrNoFact) {
|
||||
log.Printf("ambient: read %s: %v", key, err)
|
||||
http.Error(w, "read failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"read failed", fmt.Errorf("read ambient fact %q: %w", key, err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -116,8 +124,8 @@ func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, tok
|
||||
Source: calendar.SourceAmbient,
|
||||
Confidence: calendar.AmbientConfidence,
|
||||
}); err != nil {
|
||||
log.Printf("ambient: write %s: %v", key, err)
|
||||
http.Error(w, "write failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreWriteFailed,
|
||||
"write failed", fmt.Errorf("write ambient fact %q: %w", key, err))
|
||||
return
|
||||
}
|
||||
log.Printf("ambient: %s=%s (%s, pkg=%s)", key, val, calendar.SourceAmbient, n.Package)
|
||||
|
||||
@@ -16,6 +16,18 @@ import (
|
||||
|
||||
const ambientTestToken = "s3cret"
|
||||
|
||||
func TestValidateAmbientConfig(t *testing.T) {
|
||||
if err := validateAmbientConfig(false, ""); err != nil {
|
||||
t.Fatalf("explicitly disabled ambient config: %v", err)
|
||||
}
|
||||
if err := validateAmbientConfig(true, ""); err == nil {
|
||||
t.Fatal("enabled ambient ingest accepted an empty token")
|
||||
}
|
||||
if err := validateAmbientConfig(true, ambientTestToken); err != nil {
|
||||
t.Fatalf("enabled authenticated ambient config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ambientCore adds provenance-scoped reads to fakeCore, which the dedupe path
|
||||
// needs.
|
||||
type ambientCore struct {
|
||||
|
||||
+20
-15
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"log"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -47,7 +47,7 @@ var correctionTargets = []router.Intent{
|
||||
|
||||
// handleChatPage renders the chat conversation page.
|
||||
func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "chat") {
|
||||
if !requireCore(w, r, core, "chat") {
|
||||
return
|
||||
}
|
||||
msgs := []chatMsg{}
|
||||
@@ -83,13 +83,14 @@ func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
// denies, which is the point of that flag.
|
||||
func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
||||
"POST only", nil)
|
||||
return
|
||||
}
|
||||
if !requireCore(w, core, "chat") {
|
||||
if !requireCore(w, r, core, "chat") {
|
||||
return
|
||||
}
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
if !stepUpGate(w, r, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
@@ -105,8 +106,8 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
|
||||
// which is right for a single-owner box.
|
||||
reply, err := core.Chat(r.Context(), "web", text)
|
||||
if err != nil {
|
||||
log.Printf("chat api: %v", err)
|
||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreChangeFailed,
|
||||
"chat failed", fmt.Errorf("run web chat turn: %w", err))
|
||||
return
|
||||
}
|
||||
// The claiming query source rides back on the redirect so the page can show
|
||||
@@ -138,36 +139,40 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
|
||||
// id could otherwise mislabel turns he never corrected.
|
||||
func handleCorrectAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
||||
"POST only", nil)
|
||||
return
|
||||
}
|
||||
if !requireCore(w, core, "correct") {
|
||||
if !requireCore(w, r, core, "correct") {
|
||||
return
|
||||
}
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
if !stepUpGate(w, r, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("trace_id")), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
http.Error(w, "trace_id required", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"trace_id required", err)
|
||||
return
|
||||
}
|
||||
shouldBe := strings.TrimSpace(r.FormValue("should_be"))
|
||||
// Only one of the seven, or nothing. Free text here would put an unroutable
|
||||
// label in the one table V-632 fits prototypes from.
|
||||
if shouldBe != "" && !isCorrectionTarget(shouldBe) {
|
||||
http.Error(w, "should_be must be one of the seven intents", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"should_be must be one of the seven intents", nil)
|
||||
return
|
||||
}
|
||||
if err := core.CorrectTurn(r.Context(), id, shouldBe); err != nil {
|
||||
log.Printf("correct turn %d: %v", id, err)
|
||||
// A turn past the retention bound is gone, and saying so is different
|
||||
// from saying the write broke.
|
||||
if errors.Is(err, ipc.ErrNoSuchTrace) {
|
||||
http.Error(w, "that turn is no longer stored", http.StatusNotFound)
|
||||
writeProblem(w, r, http.StatusNotFound, problemResourceNotFound,
|
||||
"that turn is no longer stored", fmt.Errorf("correct turn %d: %w", id, err))
|
||||
return
|
||||
}
|
||||
http.Error(w, "correction failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreChangeFailed,
|
||||
"correction failed", fmt.Errorf("correct turn %d: %w", id, err))
|
||||
return
|
||||
}
|
||||
stamp := shouldBe
|
||||
|
||||
+14
-2
@@ -33,17 +33,29 @@ func getEco(ctx context.Context, base, path string, out any) string {
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
log.Printf("ecosystem panel request_id=%s build %q: %v", requestIDFromContext(ctx), path, err)
|
||||
return "invalid endpoint"
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Requested-By", "mavweb")
|
||||
// These are direct browser-surface reads rather than an action initiated in
|
||||
// mavend, so the HTTP request ID is the natural correlation root. Calls that
|
||||
// pass through core mint their action correlation inside mavend instead.
|
||||
if id := requestIDFromContext(ctx); id != "" {
|
||||
req.Header.Set("X-Correlation-ID", id)
|
||||
}
|
||||
resp, err := ecoClient.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem panel request_id=%s GET %s: %v", requestIDFromContext(ctx), path, err)
|
||||
return "unreachable"
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
log.Printf("ecosystem panel request_id=%s GET %s: HTTP %d", requestIDFromContext(ctx), path, resp.StatusCode)
|
||||
return fmt.Sprintf("http %d", resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
log.Printf("ecosystem panel request_id=%s decode %s: %v", requestIDFromContext(ctx), path, err)
|
||||
return "bad json"
|
||||
}
|
||||
return ""
|
||||
@@ -111,7 +123,7 @@ func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs, core
|
||||
if core == nil {
|
||||
d.Calls.Err = "not configured"
|
||||
} else if rows, err := core.RecentEcosystemTraces(ctx, 50); err != nil {
|
||||
log.Printf("ecosystem traces: %v", err)
|
||||
log.Printf("ecosystem traces request_id=%s: %v", requestIDFromContext(ctx), err)
|
||||
d.Calls.Err = "core read failed"
|
||||
} else {
|
||||
d.Calls.Rows = rows
|
||||
|
||||
@@ -75,8 +75,13 @@ func TestEventsPageReportsAReadFailure(t *testing.T) {
|
||||
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, "intake journal unavailable") ||
|
||||
!strings.Contains(body, string(problemCoreReadFailed)) ||
|
||||
!strings.Contains(body, "request ") {
|
||||
t.Errorf("page did not report a traceable, sanitized read failure: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "core is down") {
|
||||
t.Errorf("page disclosed the internal read error: %s", body)
|
||||
}
|
||||
if strings.Contains(body, "nothing has arrived yet") {
|
||||
t.Error("a failed read rendered as an empty journal")
|
||||
|
||||
+18
-12
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -32,16 +33,18 @@ var presenceSignals = map[string]string{
|
||||
// is a marker. Only allowlisted keys are accepted (see presenceSignals).
|
||||
func handleSignal(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
||||
"POST only", nil)
|
||||
return
|
||||
}
|
||||
if !requireCore(w, core, "presence ingest") {
|
||||
if !requireCore(w, r, core, "presence ingest") {
|
||||
return
|
||||
}
|
||||
key := r.URL.Query().Get("key")
|
||||
source, ok := presenceSignals[key]
|
||||
if !ok {
|
||||
http.Error(w, "unknown signal key", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"unknown signal key", nil)
|
||||
return
|
||||
}
|
||||
// kind=env: an observation about the device/surface, NOT a self-fact — a
|
||||
@@ -56,8 +59,8 @@ func handleSignal(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
Source: source,
|
||||
Confidence: 1.0,
|
||||
}); err != nil {
|
||||
log.Printf("signal %s: %v", key, err)
|
||||
http.Error(w, "write failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreWriteFailed,
|
||||
"write failed", fmt.Errorf("write presence signal %q: %w", key, err))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
@@ -65,29 +68,32 @@ func handleSignal(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
|
||||
func handleRevert(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
||||
"POST only", nil)
|
||||
return
|
||||
}
|
||||
if !requireCore(w, core, "revert") {
|
||||
if !requireCore(w, r, core, "revert") {
|
||||
return
|
||||
}
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
if !stepUpGate(w, r, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(r.FormValue("key"))
|
||||
if key == "" {
|
||||
http.Error(w, "key required", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"key required", nil)
|
||||
return
|
||||
}
|
||||
newID, err := core.RevertFact(r.Context(), key)
|
||||
if err != nil {
|
||||
log.Printf("revert %q: %v", key, err)
|
||||
if errors.Is(err, ipc.ErrNoFact) {
|
||||
http.Error(w, "no fact to revert", http.StatusNotFound)
|
||||
writeProblem(w, r, http.StatusNotFound, problemResourceNotFound,
|
||||
"no fact to revert", fmt.Errorf("revert fact %q: %w", key, err))
|
||||
return
|
||||
}
|
||||
http.Error(w, "revert failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreChangeFailed,
|
||||
"revert failed", fmt.Errorf("revert fact %q: %w", key, err))
|
||||
return
|
||||
}
|
||||
log.Printf("reverted fact for key=%s, new_id=%d", key, newID)
|
||||
|
||||
+18
-6
@@ -48,9 +48,14 @@ func main() {
|
||||
hexisURL := flag.String("hexis", "", "Hexis base URL for the /ecosystem panel (empty = not configured)")
|
||||
// Shared secret for POST /api/ambient, the notification-relay ingest that
|
||||
// reads the work calendar as a signal instead of holding a work credential
|
||||
// (see ambient.go). Empty ⇒ the route is not registered at all.
|
||||
ambientToken := flag.String("ambient-token", "", "shared secret for POST /api/ambient notification ingest (empty = ingest disabled, route not registered)")
|
||||
// (see ambient.go). Enabling and authenticating are separate on purpose: an
|
||||
// expanded-empty secret cannot silently turn a live integration off.
|
||||
ambientEnabled := flag.Bool("ambient-enabled", false, "enable POST /api/ambient notification ingest (requires -ambient-token)")
|
||||
ambientToken := flag.String("ambient-token", "", "shared secret for POST /api/ambient notification ingest")
|
||||
flag.Parse()
|
||||
if err := validateAmbientConfig(*ambientEnabled, *ambientToken); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
var core ipc.CoreAPI
|
||||
// swapConn — a second connection, for /models and nothing else. A model swap
|
||||
@@ -129,9 +134,9 @@ func main() {
|
||||
w.Write([]byte(*ntfyWS))
|
||||
})
|
||||
mux.HandleFunc("/api/signal", corePage(handleSignal))
|
||||
// Off unless configured: no token, no route — an unconfigured ingest is not
|
||||
// a 503 waiting to be probed, it does not exist.
|
||||
if *ambientToken != "" {
|
||||
// Off unless explicitly enabled: a dark ingest has no route at all, while an
|
||||
// enabled ingest with no token was rejected before the server was built.
|
||||
if *ambientEnabled {
|
||||
mux.HandleFunc("/api/ambient", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleAmbient(w, r, core, *ambientToken)
|
||||
})
|
||||
@@ -251,6 +256,13 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func validateAmbientConfig(enabled bool, token string) error {
|
||||
if enabled && token == "" {
|
||||
return errors.New("mavweb: ambient ingest is enabled but -ambient-token is empty")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// logUnguardedSurfaces names, at startup, what step-up would have covered had
|
||||
// WebAuthn been configured. One surface per line: these are read in a terminal
|
||||
// at the moment someone is deciding whether the box is safe to expose.
|
||||
@@ -288,7 +300,7 @@ const (
|
||||
func mavwebHTTPServer(addr string, handler http.Handler) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
Handler: withRequestID(handler),
|
||||
ReadHeaderTimeout: mavwebReadHeaderTimeout,
|
||||
ReadTimeout: mavwebReadTimeout,
|
||||
IdleTimeout: mavwebIdleTimeout,
|
||||
|
||||
+25
-14
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -57,7 +58,8 @@ type modelsPage struct {
|
||||
// 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)
|
||||
writeProblem(w, r, http.StatusServiceUnavailable, problemCoreUnavailable,
|
||||
"models disabled (no -core)", nil)
|
||||
return
|
||||
}
|
||||
mc, ok := swapConn, swapConn != nil
|
||||
@@ -65,7 +67,8 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swap
|
||||
mc, ok = core.(modelController)
|
||||
}
|
||||
if !ok {
|
||||
http.Error(w, "models unavailable: core connection does not support model swap", http.StatusServiceUnavailable)
|
||||
writeProblem(w, r, http.StatusServiceUnavailable, problemModelsUnavailable,
|
||||
"models unavailable: core connection does not support model swap", nil)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
@@ -73,12 +76,14 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swap
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
if !stepUpOK(session, requireStepUp) {
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
writeProblem(w, r, http.StatusForbidden, problemStepUpRequired,
|
||||
"step-up required: assert a passkey first", nil)
|
||||
return
|
||||
}
|
||||
path := strings.TrimSpace(r.FormValue("model_path"))
|
||||
if path == "" {
|
||||
http.Error(w, "model_path required", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"model_path required", nil)
|
||||
return
|
||||
}
|
||||
// Only the path comes off the form. n_ctx and n_gpu_layers are load
|
||||
@@ -93,20 +98,26 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swap
|
||||
page.Msg = "loaded " + res.Model + " (" + strconv.FormatInt(res.TookMs, 10) + "ms)"
|
||||
log.Printf("models: swapped to %s (%s) in %dms", res.ModelPath, res.Model, res.TookMs)
|
||||
case errors.Is(err, ipc.ErrForbidden):
|
||||
http.Error(w, "refused: that model is not in phraser.swap_models, or step-up was not asserted", http.StatusForbidden)
|
||||
writeProblem(w, r, http.StatusForbidden, problemModelsForbidden,
|
||||
"refused: that model is not in phraser.swap_models, or step-up was not asserted",
|
||||
fmt.Errorf("swap model %q: %w", path, err))
|
||||
return
|
||||
case errors.Is(err, ipc.ErrUnknownMethod):
|
||||
http.Error(w, "swap not configured on this core", http.StatusServiceUnavailable)
|
||||
writeProblem(w, r, http.StatusServiceUnavailable, problemModelsUnavailable,
|
||||
"swap not configured on this core", fmt.Errorf("swap model %q: %w", path, err))
|
||||
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)
|
||||
page.Err = inlineProblem(r, problemModelsUnavailable,
|
||||
"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.",
|
||||
fmt.Errorf("swap model %q and rollback: %w", 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)
|
||||
page.Err = inlineProblem(r, problemModelsUnavailable,
|
||||
"swap failed, rolled back to "+res.Model+" — she is still answering, with the old model",
|
||||
fmt.Errorf("swap model %q, rolled back to %q: %w", path, res.Model, err))
|
||||
default:
|
||||
page.Err = "swap failed: " + err.Error()
|
||||
log.Printf("models: swap to %s failed: %v", path, err)
|
||||
page.Err = inlineProblem(r, problemModelsUnavailable,
|
||||
"swap failed; the current model state is shown below",
|
||||
fmt.Errorf("swap model %q: %w", path, err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,8 +126,8 @@ func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, swap
|
||||
if errors.Is(err, ipc.ErrUnknownMethod) {
|
||||
page.Off = true
|
||||
} else {
|
||||
log.Printf("models: status: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"core read failed", fmt.Errorf("read model status: %w", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,27 @@ type errBrokenModel struct{}
|
||||
|
||||
func (errBrokenModel) Error() string { return "llm: server did not start" }
|
||||
|
||||
type errPrivateModel struct{}
|
||||
|
||||
func (errPrivateModel) Error() string { return "exec /private/llama-server: token rejected" }
|
||||
|
||||
func TestModels_SwapFailureIsSanitizedAndTraceable(t *testing.T) {
|
||||
core := &fakeModelCore{
|
||||
swapErr: errPrivateModel{},
|
||||
status: ipc.ModelStatusResp{Model: "qwen3", ModelPath: "/m/old.gguf"},
|
||||
}
|
||||
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"swap failed", string(problemModelsUnavailable), "request "} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("sanitized model error missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "/private/llama-server") || strings.Contains(body, "token rejected") {
|
||||
t.Errorf("model page disclosed the backend error:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -2,7 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"log"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -48,14 +48,14 @@ func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow {
|
||||
}
|
||||
|
||||
func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "notifications") {
|
||||
if !requireCore(w, r, core, "notifications") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
nudges, err := core.RecentNudges(ctx, 50)
|
||||
if err != nil {
|
||||
log.Printf("notifications: %v", err)
|
||||
http.Error(w, "notifications error: "+err.Error(), http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"notifications unavailable", fmt.Errorf("read recent nudges: %w", err))
|
||||
return
|
||||
}
|
||||
// The outbox, on the page that already answers "what did she send".
|
||||
@@ -66,7 +66,8 @@ func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAP
|
||||
if err != nil {
|
||||
// The nudge list is still worth showing, so this is a note on the page
|
||||
// rather than a dead page.
|
||||
log.Printf("notifications: delivery attempts: %v", err)
|
||||
logProblem(r, http.StatusOK, problemCoreReadFailed,
|
||||
"delivery attempts unavailable", fmt.Errorf("read delivery attempts: %w", err))
|
||||
}
|
||||
renderPage(w, notificationsTmpl, map[string]any{
|
||||
"Nudges": nudges,
|
||||
|
||||
+20
-19
@@ -3,8 +3,8 @@ package main
|
||||
import (
|
||||
"cmp"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -73,7 +73,7 @@ var voiceTmpl = parsePage("voice", voiceHTML, nil)
|
||||
var ecosystemTmpl = parsePage("ecosystem", ecosystemHTML, nil)
|
||||
|
||||
func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "dash") {
|
||||
if !requireCore(w, r, core, "dash") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
@@ -82,8 +82,8 @@ func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
nudges, err3 := core.RecentNudges(ctx, 50)
|
||||
notes, err4 := core.RecentNotes(ctx, 50)
|
||||
if err := cmp.Or(err1, err2, err3, err4); err != nil {
|
||||
log.Printf("dash: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"core read failed", fmt.Errorf("read dashboard: %w", err))
|
||||
return
|
||||
}
|
||||
renderPage(w, dashTmpl, struct {
|
||||
@@ -95,13 +95,13 @@ func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
}
|
||||
|
||||
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "history") {
|
||||
if !requireCore(w, r, core, "history") {
|
||||
return
|
||||
}
|
||||
facts, err := core.RecentFacts(r.Context(), 200)
|
||||
if err != nil {
|
||||
log.Printf("history: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"core read failed", fmt.Errorf("read fact history: %w", err))
|
||||
return
|
||||
}
|
||||
renderPage(w, historyTmpl, struct {
|
||||
@@ -110,13 +110,13 @@ func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
}
|
||||
|
||||
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "trace") {
|
||||
if !requireCore(w, r, core, "trace") {
|
||||
return
|
||||
}
|
||||
trace, err := core.TickTrace(r.Context())
|
||||
if err != nil {
|
||||
log.Printf("trace: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"core read failed", fmt.Errorf("read tick trace: %w", err))
|
||||
return
|
||||
}
|
||||
// The turn records share this page rather than getting one of their own
|
||||
@@ -127,7 +127,8 @@ func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
// deploy.
|
||||
turns, err := core.TurnDecisions(r.Context(), 25)
|
||||
if err != nil {
|
||||
log.Printf("trace: turn decisions: %v", err)
|
||||
logProblem(r, http.StatusOK, problemCoreReadFailed,
|
||||
"turn decisions unavailable", fmt.Errorf("read turn decisions: %w", err))
|
||||
}
|
||||
renderPage(w, traceTmpl, traceData{Tick: trace, Turns: turns})
|
||||
}
|
||||
@@ -149,14 +150,14 @@ type morningView struct {
|
||||
}
|
||||
|
||||
func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "morning") {
|
||||
if !requireCore(w, r, core, "morning") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
status, err := core.MorningStatus(ctx)
|
||||
if err != nil {
|
||||
log.Printf("morning: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"core read failed", fmt.Errorf("read morning status: %w", err))
|
||||
return
|
||||
}
|
||||
view := morningView{Routines: status}
|
||||
@@ -165,8 +166,8 @@ func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
// down with it — the page degrades to what it had before.
|
||||
plan, err := core.DayPlan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("morning: day plan: %v", err)
|
||||
view.PlanErr = err.Error()
|
||||
view.PlanErr = inlineProblem(r, problemCoreReadFailed,
|
||||
"day plan unavailable", fmt.Errorf("read day plan: %w", err))
|
||||
} else {
|
||||
view.Plan = &plan
|
||||
}
|
||||
@@ -186,14 +187,14 @@ type eventsView struct {
|
||||
const eventsPageLimit = 200
|
||||
|
||||
func handleEvents(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "intake journal") {
|
||||
if !requireCore(w, r, core, "intake journal") {
|
||||
return
|
||||
}
|
||||
var view eventsView
|
||||
evs, err := core.RecentEvents(r.Context(), eventsPageLimit)
|
||||
if err != nil {
|
||||
log.Printf("events: %v", err)
|
||||
view.Err = err.Error()
|
||||
view.Err = inlineProblem(r, problemCoreReadFailed,
|
||||
"intake journal unavailable", fmt.Errorf("read intake journal: %w", err))
|
||||
} else {
|
||||
view.Events = evs
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// problemCode is the stable, low-cardinality name a client can key on. The
|
||||
// request ID identifies one occurrence; the code identifies the class of
|
||||
// failure without exposing the wrapped implementation error.
|
||||
type problemCode string
|
||||
|
||||
const (
|
||||
problemMethodNotAllowed problemCode = "request.method_not_allowed"
|
||||
problemInvalidRequest problemCode = "request.invalid"
|
||||
problemPayloadTooLarge problemCode = "request.payload_too_large"
|
||||
problemUnauthorized problemCode = "auth.unauthorized"
|
||||
problemStepUpRequired problemCode = "auth.step_up_required"
|
||||
problemResourceNotFound problemCode = "resource.not_found"
|
||||
problemIntegrationOff problemCode = "integration.disabled"
|
||||
problemCoreUnavailable problemCode = "core.unavailable"
|
||||
problemCoreReadFailed problemCode = "core.read_failed"
|
||||
problemCoreWriteFailed problemCode = "core.write_failed"
|
||||
problemCoreChangeFailed problemCode = "core.change_failed"
|
||||
problemToolsChange problemCode = "tools.change_failed"
|
||||
problemRoutinesChange problemCode = "routines.change_failed"
|
||||
problemModelsUnavailable problemCode = "models.unavailable"
|
||||
problemModelsForbidden problemCode = "models.forbidden"
|
||||
problemWebAuthnBegin problemCode = "webauthn.begin_failed"
|
||||
problemWebAuthnFinish problemCode = "webauthn.finish_failed"
|
||||
problemWebAuthnStepUp problemCode = "webauthn.step_up_failed"
|
||||
problemVoiceUnavailable problemCode = "voice.unavailable"
|
||||
problemVoiceTransport problemCode = "voice.transport_failed"
|
||||
problemVoiceResponse problemCode = "voice.response_failed"
|
||||
)
|
||||
|
||||
type requestIDKey struct{}
|
||||
|
||||
// problemLogger is separate from the package-wide logger so the contract test
|
||||
// can capture exactly one problem line without redirecting unrelated output.
|
||||
var problemLogger = log.New(os.Stderr, "", log.LstdFlags)
|
||||
|
||||
// problemResponse is the one non-success envelope returned by mavweb. Error is
|
||||
// deliberately a public message, never err.Error(). Code is stable across
|
||||
// occurrences; request_id joins this answer to the full server-side log line.
|
||||
type problemResponse struct {
|
||||
Error string `json:"error"`
|
||||
Code problemCode `json:"code"`
|
||||
RequestID string `json:"request_id"`
|
||||
}
|
||||
|
||||
// withRequestID mints the request identifier at the HTTP boundary. A caller's
|
||||
// X-Request-ID is ignored: accepting it would let an untrusted client forge a
|
||||
// link to another request's logs. The generated ID is also returned on success,
|
||||
// which lets an operator start from any surprising response, not errors alone.
|
||||
func withRequestID(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := rand.Text()
|
||||
w.Header().Set("X-Request-ID", id)
|
||||
ctx := context.WithValue(r.Context(), requestIDKey{}, id)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func requestIDFromContext(ctx context.Context) string {
|
||||
id, _ := ctx.Value(requestIDKey{}).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
func problemRequestID(r *http.Request) string {
|
||||
id := requestIDFromContext(r.Context())
|
||||
if id == "" {
|
||||
id = rand.Text()
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func logProblem(r *http.Request, status int, code problemCode, public string, err error) string {
|
||||
id := problemRequestID(r)
|
||||
if err == nil {
|
||||
err = errors.New(public)
|
||||
}
|
||||
problemLogger.Printf("mavweb problem request_id=%s code=%s status=%d method=%s path=%q: %v",
|
||||
id, code, status, r.Method, r.URL.Path, err)
|
||||
return id
|
||||
}
|
||||
|
||||
// inlineProblem preserves a useful partial page when one panel fails, while
|
||||
// applying the same disclosure and correlation rules as an HTTP problem.
|
||||
func inlineProblem(r *http.Request, code problemCode, public string, err error) string {
|
||||
id := logProblem(r, http.StatusOK, code, public, err)
|
||||
return fmt.Sprintf("%s (code %s, request %s)", public, code, id)
|
||||
}
|
||||
|
||||
// writeProblem is the only mavweb HTTP error writer. The wrapped error is
|
||||
// logged in full and only the explicit public message, stable code and request
|
||||
// ID cross the HTTP boundary.
|
||||
func writeProblem(w http.ResponseWriter, r *http.Request, status int, code problemCode, public string, err error) {
|
||||
// Unit-level handlers and embedders may call a handler without installing
|
||||
// the server middleware. They still get the same traceable contract.
|
||||
id := logProblem(r, status, code, public, err)
|
||||
w.Header().Set("X-Request-ID", id)
|
||||
|
||||
w.Header().Set("Content-Type", "application/problem+json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
// A failed client connection leaves nowhere useful to report an encoder
|
||||
// error; the full problem is already in the server log before this write.
|
||||
_ = json.NewEncoder(w).Encode(problemResponse{
|
||||
Error: public, Code: code, RequestID: id,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWriteProblemSanitizesAndCorrelates(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
old := problemLogger
|
||||
problemLogger = logForTest(&logs)
|
||||
t.Cleanup(func() { problemLogger = old })
|
||||
|
||||
const id = "TESTREQUESTID"
|
||||
r := httptest.NewRequest(http.MethodPost, "/tools", nil)
|
||||
r = r.WithContext(context.WithValue(r.Context(), requestIDKey{}, id))
|
||||
w := httptest.NewRecorder()
|
||||
internal := errors.New("dial unix /run/private/mavend.sock: bearer secret-token")
|
||||
writeProblem(w, r, http.StatusBadGateway, problemToolsChange, "enable failed", internal)
|
||||
|
||||
if w.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502", w.Code)
|
||||
}
|
||||
if got := w.Header().Get("X-Request-ID"); got != id {
|
||||
t.Fatalf("X-Request-ID = %q, want %q", got, id)
|
||||
}
|
||||
if got := w.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/problem+json") {
|
||||
t.Fatalf("Content-Type = %q", got)
|
||||
}
|
||||
var got problemResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode problem: %v", err)
|
||||
}
|
||||
if got.Error != "enable failed" || got.Code != problemToolsChange || got.RequestID != id {
|
||||
t.Fatalf("problem = %+v", got)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "private") || strings.Contains(w.Body.String(), "secret-token") {
|
||||
t.Fatalf("HTTP response disclosed the wrapped error: %s", w.Body.String())
|
||||
}
|
||||
for _, want := range []string{id, string(problemToolsChange), internal.Error()} {
|
||||
if !strings.Contains(logs.String(), want) {
|
||||
t.Errorf("server log missing %q: %s", want, logs.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestIDMiddlewareMintsAndIgnoresCallerID(t *testing.T) {
|
||||
var seen string
|
||||
h := withRequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = requestIDFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
|
||||
r.Header.Set("X-Request-ID", "caller-chosen")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
|
||||
if seen == "" || seen == "caller-chosen" {
|
||||
t.Fatalf("request ID = %q; want a server-generated value", seen)
|
||||
}
|
||||
if got := w.Header().Get("X-Request-ID"); got != seen {
|
||||
t.Fatalf("response request ID = %q, context ID = %q", got, seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEcosystemPanelPropagatesRequestID(t *testing.T) {
|
||||
const id = "WEBREQUESTCORRELATION"
|
||||
var correlation, requester string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
correlation = r.Header.Get("X-Correlation-ID")
|
||||
requester = r.Header.Get("X-Requested-By")
|
||||
_, _ = io.WriteString(w, `[]`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
ctx := context.WithValue(context.Background(), requestIDKey{}, id)
|
||||
var rows []ecoEntity
|
||||
if got := getEco(ctx, srv.URL, "/entities", &rows); got != "" {
|
||||
t.Fatalf("getEco error = %q", got)
|
||||
}
|
||||
if correlation != id || requester != "mavweb" {
|
||||
t.Fatalf("correlation = %q, requester = %q", correlation, requester)
|
||||
}
|
||||
}
|
||||
|
||||
// The contract is architectural, not a convention people must remember. Keep
|
||||
// a syntax-level guard so a new handler cannot bypass writeProblem by adding
|
||||
// another http.Error call.
|
||||
func TestProductionHandlersUseOneProblemWriter(t *testing.T) {
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
file, err := parser.ParseFile(token.NewFileSet(), filepath.Clean(name), nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", name, err)
|
||||
}
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "Error" {
|
||||
return true
|
||||
}
|
||||
pkg, ok := sel.X.(*ast.Ident)
|
||||
if ok && pkg.Name == "http" {
|
||||
t.Errorf("%s contains http.Error; use writeProblem", name)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func logForTest(w io.Writer) *log.Logger {
|
||||
return log.New(w, "", 0)
|
||||
}
|
||||
+15
-5
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -26,6 +26,7 @@ type reminderRow struct {
|
||||
Created string
|
||||
Fires string
|
||||
Status string
|
||||
Detail string
|
||||
Text string
|
||||
}
|
||||
|
||||
@@ -50,10 +51,19 @@ func reminderText(payload string) string {
|
||||
func reminderRows(rs []ipc.Reminder) []reminderRow {
|
||||
out := make([]reminderRow, 0, len(rs))
|
||||
for _, r := range rs {
|
||||
status := r.Status
|
||||
detail := ""
|
||||
if !r.DeliveryBlockedTs.IsZero() {
|
||||
status = "blocked"
|
||||
detail = r.DeliveryBlockedError
|
||||
} else if r.DeliveryAttempts > 0 && !r.NextAttemptTs.IsZero() {
|
||||
detail = "retry " + r.NextAttemptTs.Local().Format("02 Jan 15:04")
|
||||
}
|
||||
out = append(out, reminderRow{
|
||||
Created: r.CreatedTs.Local().Format("02 Jan 15:04"),
|
||||
Fires: r.FireTs.Local().Format("02 Jan 15:04"),
|
||||
Status: r.Status,
|
||||
Status: status,
|
||||
Detail: detail,
|
||||
Text: reminderText(r.Payload),
|
||||
})
|
||||
}
|
||||
@@ -61,13 +71,13 @@ func reminderRows(rs []ipc.Reminder) []reminderRow {
|
||||
}
|
||||
|
||||
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "reminders") {
|
||||
if !requireCore(w, r, core, "reminders") {
|
||||
return
|
||||
}
|
||||
reminders, err := core.ListReminders(r.Context(), 50)
|
||||
if err != nil {
|
||||
log.Printf("reminders: %v", err)
|
||||
http.Error(w, "reminders error: "+err.Error(), http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"reminders unavailable", fmt.Errorf("list reminders: %w", err))
|
||||
return
|
||||
}
|
||||
renderPage(w, remindersTmpl, map[string]any{"Reminders": reminderRows(reminders)})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{{range .Reminders}}<tr>
|
||||
<td class=hint>{{.Created}}</td>
|
||||
<td>{{.Fires}}</td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span>{{if .Detail}}<div class=hint>{{.Detail}}</div>{{end}}</td>
|
||||
<td class=text-max>{{.Text}}</td>
|
||||
</tr>{{end}}</table></div>
|
||||
{{else}}<div class=empty>
|
||||
|
||||
@@ -31,6 +31,19 @@ func TestReminderRowsUnwrapAndLocalise(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderRowsExposeBlockedDelivery(t *testing.T) {
|
||||
blocked := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
|
||||
rows := reminderRows([]ipc.Reminder{{
|
||||
Status: "pending",
|
||||
Payload: `{"text":"позвонить врачу"}`,
|
||||
DeliveryBlockedTs: blocked,
|
||||
DeliveryBlockedError: "ntfy credentials rejected",
|
||||
}})
|
||||
if len(rows) != 1 || rows[0].Status != "blocked" || rows[0].Detail != "ntfy credentials rejected" {
|
||||
t.Fatalf("blocked reminder is not visible: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// A payload that is not the envelope is his own words, so it is shown as it is.
|
||||
func TestReminderTextKeepsPlainPayload(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
|
||||
+17
-17
@@ -5,7 +5,6 @@ import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -41,7 +40,7 @@ type routineView struct {
|
||||
// gated — it only ever removes a reason to speak, so the worst a weaker caller
|
||||
// can do is make maven quieter.
|
||||
func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if !requireCore(w, core, "routines") {
|
||||
if !requireCore(w, r, core, "routines") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
@@ -54,8 +53,8 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
|
||||
}
|
||||
proposed, err := core.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
log.Printf("routines: list: %v", err)
|
||||
http.Error(w, "routines error: "+err.Error(), http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"routines unavailable", fmt.Errorf("list proposed routines: %w", err))
|
||||
return
|
||||
}
|
||||
renderPage(w, routinesTmpl, struct {
|
||||
@@ -76,44 +75,45 @@ func applyRoutinePost(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI,
|
||||
// already the step-up-gated surface for this table, and a second gated
|
||||
// surface is a second thing to get wrong.
|
||||
if action == "seed" {
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
if !stepUpGate(w, r, session, requireStepUp) {
|
||||
return "", false
|
||||
}
|
||||
out, err := seedRoutineEvent(ctx, core, r)
|
||||
if err != nil {
|
||||
log.Printf("routines: seed: %v", err)
|
||||
http.Error(w, "seed failed: "+err.Error(), http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemRoutinesChange,
|
||||
"seed failed", fmt.Errorf("seed routine event: %w", err))
|
||||
return "", false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
idStr := r.FormValue("id")
|
||||
var rid int64
|
||||
if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
rid, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64)
|
||||
if err != nil || rid <= 0 {
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"invalid id", err)
|
||||
return "", false
|
||||
}
|
||||
switch action {
|
||||
case "accept":
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
if !stepUpGate(w, r, session, requireStepUp) {
|
||||
return "", false
|
||||
}
|
||||
if err := acceptRoutine(ctx, core, rid); err != nil {
|
||||
log.Printf("routines: accept %d: %v", rid, err)
|
||||
http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemRoutinesChange,
|
||||
"accept failed", fmt.Errorf("accept routine %d: %w", rid, err))
|
||||
return "", false
|
||||
}
|
||||
return "accepted routine — maven will remind you", true
|
||||
case "dismiss":
|
||||
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
||||
log.Printf("routines: dismiss %d: %v", rid, err)
|
||||
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemRoutinesChange,
|
||||
"dismiss failed", fmt.Errorf("dismiss routine %d: %w", rid, err))
|
||||
return "", false
|
||||
}
|
||||
return "dismissed routine", true
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"unknown action", nil)
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -159,9 +159,10 @@ func renderPage(w http.ResponseWriter, t *template.Template, data any) {
|
||||
// requireCore answers whether the surface has a core to read. mavweb runs
|
||||
// without -core (voice-only), and every page that needs mavend says so with a
|
||||
// 503 naming itself rather than a blank error.
|
||||
func requireCore(w http.ResponseWriter, core ipc.CoreAPI, surface string) bool {
|
||||
func requireCore(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, surface string) bool {
|
||||
if core == nil {
|
||||
http.Error(w, surface+" disabled (no -core)", http.StatusServiceUnavailable)
|
||||
writeProblem(w, r, http.StatusServiceUnavailable, problemCoreUnavailable,
|
||||
surface+" disabled (no -core)", nil)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -169,11 +170,12 @@ func requireCore(w http.ResponseWriter, core ipc.CoreAPI, surface string) bool {
|
||||
|
||||
// stepUpGate reports whether the caller may proceed through the AuthStepUp
|
||||
// gate, writing the 403 itself when it may not. See stepUpOK for the policy.
|
||||
func stepUpGate(w http.ResponseWriter, session *webauthn.PasskeySession, requireStepUp bool) bool {
|
||||
func stepUpGate(w http.ResponseWriter, r *http.Request, session *webauthn.PasskeySession, requireStepUp bool) bool {
|
||||
if stepUpOK(session, requireStepUp) {
|
||||
return true
|
||||
}
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
writeProblem(w, r, http.StatusForbidden, problemStepUpRequired,
|
||||
"step-up required: assert a passkey first", nil)
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
+52
-21
@@ -5,7 +5,6 @@ import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -88,7 +87,7 @@ func rowOf(t ipc.Task) taskRow {
|
||||
// from something she read into work he owns. That review step is why derived
|
||||
// tasks are captured as candidates in the first place.
|
||||
func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
if !requireCore(w, core, "tasks") {
|
||||
if !requireCore(w, r, core, "tasks") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
@@ -97,15 +96,14 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
var err error
|
||||
msg, err = applyTaskPost(ctx, core, r)
|
||||
if err != nil {
|
||||
log.Printf("tasks: %v", err)
|
||||
errMsg = err.Error()
|
||||
errMsg = inlineProblem(r, problemCoreChangeFailed, taskPublicMessage(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
all, err := core.ListTasks(ctx, "")
|
||||
if err != nil {
|
||||
log.Printf("tasks: list: %v", err)
|
||||
http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"tasks unavailable", fmt.Errorf("list tasks: %w", err))
|
||||
return
|
||||
}
|
||||
// Live rows are ordered by the same ranker the spoken list uses, so the page
|
||||
@@ -162,6 +160,39 @@ func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
}{msg, errMsg, tasks.Stalls(live, now()), cands, open, resolved, resolvedTotal > len(resolved)})
|
||||
}
|
||||
|
||||
// taskInputError is a form/domain refusal safe to show back to the owner. IPC,
|
||||
// storage and transport errors never use this type and therefore receive the
|
||||
// generic task failure text plus a request reference.
|
||||
type taskInputError struct{ message string }
|
||||
|
||||
func (e *taskInputError) Error() string { return e.message }
|
||||
|
||||
func taskInput(message string) error { return &taskInputError{message: message} }
|
||||
|
||||
func taskInputf(format string, args ...any) error {
|
||||
return &taskInputError{message: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
type taskPartialError struct {
|
||||
public string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *taskPartialError) Error() string { return e.public + ": " + e.err.Error() }
|
||||
func (e *taskPartialError) Unwrap() error { return e.err }
|
||||
|
||||
func taskPublicMessage(err error) string {
|
||||
var input *taskInputError
|
||||
if errors.As(err, &input) {
|
||||
return input.message
|
||||
}
|
||||
var partial *taskPartialError
|
||||
if errors.As(err, &partial) {
|
||||
return partial.public
|
||||
}
|
||||
return "task update failed"
|
||||
}
|
||||
|
||||
// applyTaskPost performs one write and returns the message to show. A bad
|
||||
// request returns an error, which the page renders inline rather than as a
|
||||
// bare 400 — this is a form surface, not an API.
|
||||
@@ -170,7 +201,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
if action == "add" {
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
return "", errors.New("empty task text")
|
||||
return "", taskInput("empty task text")
|
||||
}
|
||||
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: now()}
|
||||
wgt, err := formWeight(r)
|
||||
@@ -198,7 +229,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
|
||||
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid id")
|
||||
return "", taskInput("invalid id")
|
||||
}
|
||||
|
||||
if action == "promote" {
|
||||
@@ -214,7 +245,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
// is not editable here: that ladder is one-way and has its own buttons.
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
return "", errors.New("empty task text")
|
||||
return "", taskInput("empty task text")
|
||||
}
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
@@ -230,9 +261,9 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
case errors.Is(err, ipc.ErrTaskDuplicate):
|
||||
// Naming the collision instead of merging: two live rows carry two
|
||||
// provenances, and picking one is not the page's call.
|
||||
return "", errors.New("another open task already says this — drop one of the two")
|
||||
return "", taskInput("another open task already says this — drop one of the two")
|
||||
case errors.Is(err, ipc.ErrTaskResolved):
|
||||
return "", errors.New("a resolved task keeps the text it was finished under")
|
||||
return "", taskInput("a resolved task keeps the text it was finished under")
|
||||
default:
|
||||
return "", err
|
||||
}
|
||||
@@ -247,7 +278,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
case "drop":
|
||||
status, msg = "dropped", "dropped task"
|
||||
default:
|
||||
return "", fmt.Errorf("unknown action %q", action)
|
||||
return "", taskInputf("unknown action %q", action)
|
||||
}
|
||||
if err := core.SetTaskStatus(ctx, id, status, now(), "tap:web"); err != nil {
|
||||
return "", statusWriteErr(err)
|
||||
@@ -257,7 +288,7 @@ func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (stri
|
||||
|
||||
// errNoDoneWhen — the refusal has to name what is missing, or the button looks
|
||||
// broken. The field it asks for arrives with the intake form (Vikunja #511).
|
||||
var errNoDoneWhen = errors.New("write a definition of done before confirming this candidate")
|
||||
var errNoDoneWhen = taskInput("write a definition of done before confirming this candidate")
|
||||
|
||||
// statusWriteErr translates a SetTaskStatus failure into what the page says.
|
||||
func statusWriteErr(err error) error {
|
||||
@@ -281,11 +312,11 @@ func statusWriteErr(err error) error {
|
||||
func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id int64) (string, error) {
|
||||
doneWhen := strings.TrimSpace(r.FormValue("done_when"))
|
||||
if doneWhen == "" {
|
||||
return "", errors.New("write a definition of done — what has to be true for this to be finished")
|
||||
return "", taskInput("write a definition of done — what has to be true for this to be finished")
|
||||
}
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
return "", errors.New("empty task text")
|
||||
return "", taskInput("empty task text")
|
||||
}
|
||||
due, err := formDue(r, now())
|
||||
if err != nil {
|
||||
@@ -326,7 +357,7 @@ func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id
|
||||
if _, err := core.CreateReminder(ctx, fire, text, ""); err != nil {
|
||||
// The task IS promoted; only the reminder failed. Saying "confirmed"
|
||||
// and nothing else would leave him expecting a nudge that will not come.
|
||||
return "", fmt.Errorf("confirmed, but the reminder did not save: %w", err)
|
||||
return "", &taskPartialError{public: "task confirmed, but the reminder did not save", err: err}
|
||||
}
|
||||
return "confirmed, and maven will remind you that morning", nil
|
||||
}
|
||||
@@ -343,15 +374,15 @@ func resolveBlocker(ctx context.Context, core ipc.CoreAPI, field string) (string
|
||||
ref, err := core.ResolveEntity(ctx, name, []string{"person"})
|
||||
switch {
|
||||
case errors.Is(err, ipc.ErrNotImplemented):
|
||||
return "", errors.New("no identity service here, so blocked-on cannot be stored — leave it empty")
|
||||
return "", taskInput("no identity service here, so blocked-on cannot be stored — leave it empty")
|
||||
case errors.Is(err, ipc.ErrNoEntity):
|
||||
return "", fmt.Errorf("nexus does not know %q", name)
|
||||
return "", taskInputf("nexus does not know %q", name)
|
||||
case err != nil:
|
||||
return "", fmt.Errorf("resolving %q: %w", name, err)
|
||||
case ref.Ambiguous:
|
||||
// Asking, not picking: a task blocked on the wrong person is a
|
||||
// mistake nobody can see afterwards.
|
||||
return "", fmt.Errorf("%q matches %s — say which", name, strings.Join(ref.Candidates, ", "))
|
||||
return "", taskInputf("%q matches %s — say which", name, strings.Join(ref.Candidates, ", "))
|
||||
}
|
||||
return ref.ID, nil
|
||||
}
|
||||
@@ -366,7 +397,7 @@ func formWeight(r *http.Request) (int, error) {
|
||||
}
|
||||
wgt, err := strconv.Atoi(v)
|
||||
if err != nil || wgt < 0 {
|
||||
return 0, fmt.Errorf("bad weight %q", v)
|
||||
return 0, taskInputf("bad weight %q", v)
|
||||
}
|
||||
if wgt > tasks.MaxWeight {
|
||||
wgt = tasks.MaxWeight
|
||||
@@ -383,7 +414,7 @@ func formDue(r *http.Request, now time.Time) (*time.Time, error) {
|
||||
}
|
||||
due, err := time.ParseInLocation("2006-01-02", d, now.Location())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bad due date %q", d)
|
||||
return nil, taskInputf("bad due date %q", d)
|
||||
}
|
||||
return &due, nil
|
||||
}
|
||||
|
||||
@@ -191,6 +191,24 @@ func TestHandleTasksRejectsBadPost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTasksSanitizesCoreWriteFailure(t *testing.T) {
|
||||
core := &fakeTaskCore{captureErr: fmt.Errorf("sqlite /private/maven.db: key material rejected")}
|
||||
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)
|
||||
body := rec.Body.String()
|
||||
for _, want := range []string{"task update failed", string(problemCoreChangeFailed), "request "} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("sanitized task error missing %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "/private/maven.db") || strings.Contains(body, "key material") {
|
||||
t.Errorf("task page disclosed the core error: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleTasksNoCore(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), nil)
|
||||
|
||||
+21
-16
@@ -3,8 +3,8 @@ package main
|
||||
import (
|
||||
"cmp"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -32,13 +32,13 @@ var toolsTmpl = parsePage("tools", toolsHTML, template.FuncMap{
|
||||
// shell-word parsing; the box owner controls this input, quote a wrapper script
|
||||
// if an arg needs spaces).
|
||||
func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||
if !requireCore(w, core, "tools") {
|
||||
if !requireCore(w, r, core, "tools") {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var msg string
|
||||
if r.Method == http.MethodPost {
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
if !stepUpGate(w, r, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
action := r.FormValue("action")
|
||||
@@ -49,54 +49,59 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sessi
|
||||
cmd := strings.Fields(r.FormValue("cmd"))
|
||||
destructive := r.FormValue("destructive") != ""
|
||||
if name == "" || len(cmd) == 0 {
|
||||
http.Error(w, "name and cmd required", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"name and cmd required", nil)
|
||||
return
|
||||
}
|
||||
if err := core.EnableTool(ctx, name, cmd, destructive, scope, time.Now()); err != nil {
|
||||
log.Printf("tools: enable %q: %v", name, err)
|
||||
http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemToolsChange,
|
||||
"enable failed", fmt.Errorf("enable tool %q: %w", name, err))
|
||||
return
|
||||
}
|
||||
msg = "enabled " + name
|
||||
case "disable":
|
||||
if name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"name required", nil)
|
||||
return
|
||||
}
|
||||
if err := core.DisableTool(ctx, name); err != nil {
|
||||
log.Printf("tools: disable %q: %v", name, err)
|
||||
http.Error(w, "disable failed: "+err.Error(), http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemToolsChange,
|
||||
"disable failed", fmt.Errorf("disable tool %q: %w", name, err))
|
||||
return
|
||||
}
|
||||
msg = "disabled " + name
|
||||
case "dismiss":
|
||||
if name == "" {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"name required", nil)
|
||||
return
|
||||
}
|
||||
if err := core.DeleteTool(ctx, name); err != nil {
|
||||
log.Printf("tools: dismiss %q: %v", name, err)
|
||||
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemToolsChange,
|
||||
"dismiss failed", fmt.Errorf("dismiss tool %q: %w", name, err))
|
||||
return
|
||||
}
|
||||
msg = "dismissed " + name
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"unknown action", nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
proposed, err1 := core.ListTools(ctx, "proposed")
|
||||
enabled, err2 := core.ListTools(ctx, "enabled")
|
||||
if err := cmp.Or(err1, err2); err != nil {
|
||||
log.Printf("tools: %v", err)
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
|
||||
"core read failed", fmt.Errorf("list tools: %w", err))
|
||||
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)
|
||||
logProblem(r, http.StatusOK, problemCoreReadFailed,
|
||||
"MCP server status unavailable", fmt.Errorf("read MCP server status: %w", err))
|
||||
servers = nil
|
||||
}
|
||||
// Enabled rows are shown grouped by capability domain (Vikunja #452). A
|
||||
|
||||
+19
-13
@@ -46,24 +46,28 @@ func pushToTalk(pcm []byte) voice.Request {
|
||||
|
||||
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", http.StatusMethodNotAllowed)
|
||||
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
||||
"POST only", nil)
|
||||
return
|
||||
}
|
||||
if !stepUpGate(w, session, requireStepUp) {
|
||||
if !stepUpGate(w, r, session, requireStepUp) {
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxPTTAudioBytes))
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
http.Error(w, "audio exceeds the ten-minute PTT limit", http.StatusRequestEntityTooLarge)
|
||||
writeProblem(w, r, http.StatusRequestEntityTooLarge, problemPayloadTooLarge,
|
||||
"audio exceeds the ten-minute PTT limit", err)
|
||||
return
|
||||
}
|
||||
http.Error(w, "read audio", http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"read audio", fmt.Errorf("read PTT audio: %w", err))
|
||||
return
|
||||
}
|
||||
if len(body) < 4 {
|
||||
http.Error(w, "too short", 400)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"audio too short", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -72,36 +76,38 @@ func handlePTT(w http.ResponseWriter, r *http.Request, voiceAddr string, session
|
||||
var d net.Dialer
|
||||
tc, err := d.DialContext(r.Context(), "tcp", voiceAddr)
|
||||
if err != nil {
|
||||
log.Printf("ptt dial voice: %v", err)
|
||||
http.Error(w, "voice unavailable", http.StatusServiceUnavailable)
|
||||
writeProblem(w, r, http.StatusServiceUnavailable, problemVoiceUnavailable,
|
||||
"voice unavailable", fmt.Errorf("dial voice service: %w", err))
|
||||
return
|
||||
}
|
||||
defer tc.Close()
|
||||
|
||||
req := pushToTalk(body)
|
||||
if err := writeFrame(tc, &req); err != nil {
|
||||
log.Printf("ptt write: %v", err)
|
||||
http.Error(w, err.Error(), 500)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemVoiceTransport,
|
||||
"voice request failed", fmt.Errorf("write voice request: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
resp, push, err := readOneFrame(tc)
|
||||
if err != nil {
|
||||
log.Printf("ptt read: %v", err)
|
||||
http.Error(w, err.Error(), 500)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemVoiceTransport,
|
||||
"voice response failed", fmt.Errorf("read voice response: %w", err))
|
||||
return
|
||||
}
|
||||
if push != nil {
|
||||
continue
|
||||
}
|
||||
if resp.Error != nil {
|
||||
http.Error(w, resp.Error.Message, 500)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemVoiceResponse,
|
||||
"voice turn failed", fmt.Errorf("voice RPC error: %s", resp.Error.Message))
|
||||
return
|
||||
}
|
||||
var pttResp voice.PushToTalkResp
|
||||
if err := json.Unmarshal(resp.Result, &pttResp); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemVoiceResponse,
|
||||
"voice response failed", fmt.Errorf("decode voice response: %w", err))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "audio/l16;rate=16000;channels=1")
|
||||
|
||||
+18
-14
@@ -92,8 +92,8 @@ var passkeyTmpl = parsePage("passkey", passkeyPageHTML, nil)
|
||||
func (h *PasskeyHandle) RegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||
opts, challenge, err := h.rp.CreationOptions([]byte("maven-user"), "maven user")
|
||||
if err != nil {
|
||||
log.Printf("webauthn: register begin: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeProblem(w, r, http.StatusInternalServerError, problemWebAuthnBegin,
|
||||
"passkey registration could not start", fmt.Errorf("webauthn register begin: %w", err))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -102,7 +102,8 @@ func (h *PasskeyHandle) RegisterBegin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
||||
"POST only", nil)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -110,7 +111,8 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
Credential map[string]any `json:"credential"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"invalid registration request", fmt.Errorf("decode webauthn registration: %w", err))
|
||||
return
|
||||
}
|
||||
save := func(id string, publicKey []byte, _ []byte, _ string) error {
|
||||
@@ -118,8 +120,8 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
credID, err := h.rp.FinishRegistration(save, body.Challenge, body.Credential)
|
||||
if err != nil {
|
||||
log.Printf("webauthn: register finish: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemWebAuthnFinish,
|
||||
"passkey registration failed", fmt.Errorf("webauthn register finish: %w", err))
|
||||
return
|
||||
}
|
||||
log.Printf("webauthn: registered credential %s", credID)
|
||||
@@ -140,8 +142,8 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PasskeyHandle) AssertBegin(w http.ResponseWriter, r *http.Request) {
|
||||
opts, challenge, err := h.rp.AssertionOptions()
|
||||
if err != nil {
|
||||
log.Printf("webauthn: assert begin: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeProblem(w, r, http.StatusInternalServerError, problemWebAuthnBegin,
|
||||
"passkey assertion could not start", fmt.Errorf("webauthn assert begin: %w", err))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -150,7 +152,8 @@ func (h *PasskeyHandle) AssertBegin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed,
|
||||
"POST only", nil)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
@@ -176,7 +179,8 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
Explicit bool `json:"explicit"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest,
|
||||
"invalid assertion request", fmt.Errorf("decode webauthn assertion: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -189,8 +193,8 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
credID, err := h.rp.FinishAssertion(lookup, update, body.Challenge, body.Credential)
|
||||
if err != nil {
|
||||
log.Printf("webauthn: assert finish: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeProblem(w, r, http.StatusBadRequest, problemWebAuthnFinish,
|
||||
"passkey assertion failed", fmt.Errorf("webauthn assert finish: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -201,8 +205,8 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := h.assertFn.AssertStepUp(ctx); err != nil {
|
||||
log.Printf("webauthn: assert step-up: %v", err)
|
||||
http.Error(w, "step-up assertion failed", http.StatusBadGateway)
|
||||
writeProblem(w, r, http.StatusBadGateway, problemWebAuthnStepUp,
|
||||
"step-up assertion failed", fmt.Errorf("assert step-up in core: %w", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ dir and read-only models.
|
||||
cp deploy/db_key.env.example deploy/db_key.env
|
||||
printf 'MAVEN_DB_KEY=%s\n' "$(openssl rand 32 | base64 -w0)" > deploy/db_key.env
|
||||
|
||||
# 1b. review the complete secret inventory. Copy homesrv values to
|
||||
# deploy/telegram.env, ambient enable/token values to .env, and workpc values
|
||||
# to the destinations named in the example. Live blocks reject empty secrets.
|
||||
cp deploy/telegram.env.example deploy/telegram.env
|
||||
|
||||
# 2. build + start
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
|
||||
+17
-2
@@ -20,6 +20,7 @@ every stutter on the way there.
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -71,6 +72,18 @@ def authorised(headers):
|
||||
return hmac.compare_digest(got, "Bearer " + TOKEN)
|
||||
|
||||
|
||||
def validate_listener_auth(host, token):
|
||||
"""Refuse a network listener without its bearer boundary."""
|
||||
try:
|
||||
loopback = ipaddress.ip_address(host).is_loopback
|
||||
except ValueError:
|
||||
loopback = host.casefold() == "localhost"
|
||||
if not loopback and not token.strip():
|
||||
raise ValueError(
|
||||
f"CW2_TOKEN is required while CW2_HOST={host!r} is reachable from the network"
|
||||
)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
@@ -142,8 +155,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
|
||||
|
||||
def main():
|
||||
if not TOKEN:
|
||||
log.warning("no CW2_TOKEN set: anything on the LAN can post audio here")
|
||||
try:
|
||||
validate_listener_auth(HOST, TOKEN)
|
||||
except ValueError as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
# Bind before loading, so a restart answers 503 rather than refusing the
|
||||
# connection. Both make Maven fall back, but only one of them says why.
|
||||
srv = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import unittest
|
||||
|
||||
import serve
|
||||
|
||||
|
||||
class ListenerAuthTest(unittest.TestCase):
|
||||
def test_network_listener_requires_token(self):
|
||||
for host in ("0.0.0.0", "192.168.1.105", "::"):
|
||||
for token in ("", " "):
|
||||
with self.subTest(host=host, token=token):
|
||||
with self.assertRaises(ValueError):
|
||||
serve.validate_listener_auth(host, token)
|
||||
|
||||
def test_loopback_listener_may_be_explicitly_unauthenticated(self):
|
||||
for host in ("127.0.0.1", "::1", "localhost"):
|
||||
with self.subTest(host=host):
|
||||
serve.validate_listener_auth(host, "")
|
||||
|
||||
def test_network_listener_accepts_token(self):
|
||||
serve.validate_listener_auth("0.0.0.0", "secret")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+7
-4
@@ -31,14 +31,14 @@
|
||||
"rule — three things in series that have each failed once, and when they do",
|
||||
"a sev4 nudge has nowhere to go. ntfy shares none of them: it is reached",
|
||||
"directly, no relay.",
|
||||
"It is not only a spare. The routing table sends sev3-away and away",
|
||||
"reminders here and NOWHERE else, so with this block absent those two",
|
||||
"routes hit a nil sink and vanish without a log or an outbox row.",
|
||||
"It is the preferred away reach. Reminders fall back to Telegram in order,",
|
||||
"and every missing reach is recorded in the outbox rather than disappearing.",
|
||||
"The credential is an ntfy access token, scoped write-only to this one",
|
||||
"topic, so a popped sink can push to it and cannot read it back. Set it in",
|
||||
"deploy/telegram.env beside the telegram secrets; that file is gitignored."
|
||||
],
|
||||
"ntfy": {
|
||||
"disabled": true,
|
||||
"base_url": "https://ntfy.kvmx.ru",
|
||||
"topic": "maven",
|
||||
"token": "${NTFY_TOKEN}"
|
||||
@@ -76,7 +76,9 @@
|
||||
"resident model without saying so. Deleting this block restores exactly",
|
||||
"the behaviour homesrv had before it existed.",
|
||||
"Addressed by LAN address, not container name: mavgpud runs on another",
|
||||
"machine and there is no shared docker network to name it on."
|
||||
"machine and there is no shared docker network to name it on.",
|
||||
"model_disabled keeps only this model arm dark until MAVEN_GPU_TOKEN is",
|
||||
"provisioned; the independently authenticated STT arm below stays live."
|
||||
],
|
||||
"//workstation.stt": [
|
||||
"CrisperWhisper 2.0 turbo on the same machine, a second service on port",
|
||||
@@ -93,6 +95,7 @@
|
||||
"The token is what stops anything on the LAN posting audio to that port."
|
||||
],
|
||||
"workstation": {
|
||||
"model_disabled": true,
|
||||
"url": "http://192.168.1.105:8080",
|
||||
"token": "${MAVEN_GPU_TOKEN}",
|
||||
"probe": "15s",
|
||||
|
||||
@@ -16,6 +16,34 @@ NTFY_TOKEN=
|
||||
# the card. Any long random string; mint one with:
|
||||
# openssl rand -hex 32
|
||||
# The same value goes in a file on workpc, named by token_file in
|
||||
# deploy/mavgpud.json. Unset here and every workstation turn falls back to the
|
||||
# resident model, because mavgpud answers 401 and Maven reads that as down.
|
||||
# deploy/mavgpud.json. Empty is valid only while workstation.model_disabled is
|
||||
# true; enabling the LAN model arm without it fails Maven's config validation.
|
||||
MAVEN_GPU_TOKEN=
|
||||
|
||||
# Bearer token used by Maven when it sends audio to the workstation's
|
||||
# CrisperWhisper service. The matching CW2_TOKEN is installed on workpc; these
|
||||
# two values must be identical. Leaving both unset is only safe while that
|
||||
# remote STT service is bound to loopback or its STT block is disabled.
|
||||
MAVEN_STT_TOKEN=
|
||||
|
||||
# Home Assistant is currently explicitly disabled in deploy/mavend.json. Keep
|
||||
# this name in the canonical inventory so enabling that block cannot turn into
|
||||
# a valid-looking configuration with an expanded-empty token.
|
||||
HA_TOKEN=
|
||||
|
||||
# docker compose reads these from the repository-root `.env`, before it starts
|
||||
# containers; they are listed here even though their destination differs so
|
||||
# this remains the complete deployment inventory. Copy both lines to `.env`.
|
||||
# Enabling with an empty token fails startup; false is the explicit dark state.
|
||||
# MAVEN_AMBIENT_ENABLED=false
|
||||
# MAVEN_AMBIENT_TOKEN=
|
||||
|
||||
# Workstation-side counterpart to MAVEN_STT_TOKEN. Install this line in the
|
||||
# EnvironmentFile named by deploy/mavgpud.service (currently
|
||||
# %h/Programs/cw2-service/cw2.env), not in homesrv's telegram.env.
|
||||
# CW2_TOKEN=
|
||||
|
||||
# The encrypted database key has its own narrowly mounted file,
|
||||
# deploy/db_key.env, and its own generator in deploy/README.md. It is named
|
||||
# here so an operator auditing this one inventory sees every deployed secret.
|
||||
# MAVEN_DB_KEY=
|
||||
|
||||
+7
-5
@@ -97,15 +97,17 @@ services:
|
||||
<<: *image
|
||||
# voice.bind is 0.0.0.0:9100 in deploy/mavend.json so mavweb can reach it
|
||||
# cross-container. Verified 2026-07-06.
|
||||
# -ambient-token turns on POST /api/ambient (Vikunja #126): the phone posts
|
||||
# notification text, mavweb keeps only a meeting time. Empty ⇒ no route at
|
||||
# all, which is what a missing MAVEN_AMBIENT_TOKEN gives. The value comes
|
||||
# from the gitignored .env docker compose reads for interpolation, NOT from
|
||||
# an env_file — flags are interpolated before any service env exists.
|
||||
# -ambient-enabled turns on POST /api/ambient (Vikunja #126): the phone posts
|
||||
# notification text, mavweb keeps only a meeting time. The explicit enabled
|
||||
# flag prevents an unset token from looking like an intentional dark state.
|
||||
# Both values come from the gitignored .env docker compose reads for
|
||||
# interpolation, NOT from an env_file — flags are interpolated before any
|
||||
# service env exists.
|
||||
# Weakness worth naming: mavweb takes this as a flag, so it is visible in
|
||||
# `ps` inside this container, unlike the zenmoney and IMAP secrets which are
|
||||
# read from files.
|
||||
command: ["mavweb", "-addr", ":9201", "-voice", "mavend:9100", "-core", "/run/maven/mavend.sock",
|
||||
"-ambient-enabled", "${MAVEN_AMBIENT_ENABLED:-false}",
|
||||
"-ambient-token", "${MAVEN_AMBIENT_TOKEN:-}",
|
||||
"-nexus", "http://nexus:9740", "-praxis", "http://praxis:8989", "-hexis", "http://hexis:9741"]
|
||||
depends_on: [mavend]
|
||||
|
||||
@@ -22,27 +22,26 @@ caveat is the pointer between them plus the trigger.
|
||||
|
||||
Every entry below came from the 2026-08-10 deep audit
|
||||
(`docs/evals/2026-08-10-repo-audit.md`), except the last, which came from wiring
|
||||
the gate the audit asked for. Nine of the twenty findings are fixed and have no
|
||||
the gate the audit asked for. Twelve of the twenty findings are fixed and have no
|
||||
entry. The unauthenticated mavgpud proxy was V-673. The 20 reachable advisories
|
||||
in the toolchain and `x/text` were V-682. The missing analyzers were V-694, and
|
||||
what they now report is the baseline entry under V-701. The invalid STT and
|
||||
weather responses were V-675/V-676, the stuck TCP close was V-679, and the
|
||||
unbounded PTT path was V-688. The two unguarded
|
||||
invariants were V-692 and V-693, and their guards are described in
|
||||
`docs/routing.md`.
|
||||
`docs/routing.md`. The complete secret inventory and fail-closed integration
|
||||
validation were V-691. The atomic reminder transition was V-678. The traceable,
|
||||
sanitized mavweb problem contract was V-689.
|
||||
|
||||
| limit | severity |
|
||||
| --- | --- |
|
||||
| [Anyone past the proxy can enroll a passkey](security.md#enrollment) | high |
|
||||
| [Passkey credentials are rewritten in place](security.md#credentials) | medium |
|
||||
| [Dialogue persistence errors are swallowed](storage.md#dialogue) | medium |
|
||||
| [The reminder transition is a lost update](storage.md#reminders) | medium |
|
||||
| [A recall miss scans two whole tables](storage.md#recall) | medium |
|
||||
| [mavweb errors cannot be traced](transport.md#errors) | medium |
|
||||
| [Fact enrichment is a 20-call serial waterfall](workers.md#enrichment) | medium |
|
||||
| [A suppressed nudge is phrased anyway](workers.md#nudges) | medium |
|
||||
| [Committed absolute paths pin the build to this box](config.md#paths) | medium |
|
||||
| [The env example omits deployed variables](config.md#secrets) | medium |
|
||||
| [The analyzers pass against a baseline, not zero](dependencies.md#baseline) | medium |
|
||||
| [Domain packages depend on store and IPC types](layering.md#dtos) | low |
|
||||
| [Eleven symbols are unreachable](layering.md#deadcode) | low |
|
||||
|
||||
@@ -8,14 +8,3 @@ workstation model and Python paths. Vendoring hides the `go.mod` problem for an
|
||||
ordinary build. `-mod=mod`, `go mod tidy` and a fresh checkout all fail.
|
||||
Revisit when: anyone clones this repo elsewhere, or a `tidy` is needed.
|
||||
Workaround: build only from this checkout, with the vendor directory.
|
||||
|
||||
## The env example omits deployed variables [#691] {#secrets}
|
||||
|
||||
Costs: a fresh deploy can lose remote speech-to-text or ambient authentication
|
||||
and run on fallback behaviour with an apparently valid config. Three variables
|
||||
are referenced and undocumented: `MAVEN_STT_TOKEN`, `MAVEN_AMBIENT_TOKEN` and
|
||||
`CW2_TOKEN`. V-673 added `MAVEN_GPU_TOKEN` to the example.
|
||||
It is not silent. The loader logs which variables were unset and says whatever
|
||||
they configure is off. What is missing is a startup failure.
|
||||
Revisit when: the box is redeployed from scratch, or a new secret is added.
|
||||
Workaround: read that log line at startup.
|
||||
|
||||
@@ -11,14 +11,6 @@ Revisit when: a restart is reported as losing context. Sooner if a turn starts
|
||||
reading dialogue rows back to him.
|
||||
Workaround: none. The failure is invisible from outside.
|
||||
|
||||
## The reminder transition is a lost update [#678] {#reminders}
|
||||
|
||||
Costs: a concurrent fire and cancel both succeed and the last writer wins.
|
||||
Medium today because cancellation has no surface. High the moment V-622 adds
|
||||
one, and V-622 does not describe this invariant.
|
||||
Revisit when: V-622 starts, whichever comes first.
|
||||
Workaround: none, but the window is small while nothing can cancel.
|
||||
|
||||
## A recall miss scans two whole tables [#681] {#recall}
|
||||
|
||||
Costs: every missed recall reads all of `memory_vectors` and then decodes and
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# Transport
|
||||
|
||||
The HTTP and socket seams. What a client can do to them, and what a shutdown
|
||||
can do to us.
|
||||
|
||||
## mavweb errors cannot be traced [#689] {#errors}
|
||||
|
||||
Costs: some handlers return the raw internal error, which discloses internals.
|
||||
Others return a generic one with no identifier, which cannot be joined to its
|
||||
log line. There is no request-id middleware to join them.
|
||||
Revisit when: a reported UI failure cannot be found in the log.
|
||||
Workaround: read the log by timestamp.
|
||||
+16
-1
@@ -78,7 +78,10 @@ vocabulary size. CW2's 51897 tokens shift seven special token ids. So CW2 is its
|
||||
own transformers service on port 8081 (`deploy/cw2/serve.py`).
|
||||
`stt.HTTPTranscriber` posts raw PCM to it with a bearer token, because audio is
|
||||
the most sensitive thing that crosses this seam. The switch is `workstation.stt`
|
||||
in `deploy/mavend.json`, and deleting the block sends every utterance to mavsttd.
|
||||
in `deploy/mavend.json`. A LAN URL with an empty token fails config validation;
|
||||
`workstation.stt.disabled: true` is the STT-only dark state;
|
||||
`workstation.disabled: true` disables both remote arms. The model has its own
|
||||
`model_disabled` switch because CW2 can remain live without the model proxy.
|
||||
|
||||
**mavgpud runs that service as a second child.** This is not an optimisation.
|
||||
CW2 is a ROCm process on the same card, so it registers on the KFD like any
|
||||
@@ -208,6 +211,18 @@ rule.
|
||||
|
||||
## Web UI conventions
|
||||
|
||||
Every HTTP response carries a server-generated `X-Request-ID`. Error responses
|
||||
use `application/problem+json` with three fields: a sanitized `error`, a stable
|
||||
low-cardinality `code`, and that `request_id`. `writeProblem` is the only error
|
||||
writer: it records the full wrapped error beside the same ID in the mavweb log,
|
||||
while implementation paths, transport messages and credentials never reach the
|
||||
browser. A page that can usefully degrade instead of failing keeps HTTP 200 and
|
||||
renders the same code/request reference beside its sanitized panel error.
|
||||
|
||||
The `/ecosystem` panel uses its HTTP request ID as `X-Correlation-ID` on its
|
||||
direct Nexus, Praxis and Hexis reads. Calls routed through mavend retain
|
||||
mavend's action-scoped correlation contract instead.
|
||||
|
||||
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and
|
||||
the shell partial in `cmd/mavweb/shell.html`. A page opens with
|
||||
`{{template "shellTop" "<page-key>"}}` and closes with `{{template "shellBottom"}}`,
|
||||
|
||||
+5
-2
@@ -113,8 +113,11 @@ read `/slots`, which returns other callers' prompts. mavgpud now reads
|
||||
network without one. Downgrading to loopback instead would look safe and take
|
||||
the model arm down. Maven sends the same token from `workstation.token`, on the
|
||||
completion and on the `/health` probe alike. An unsigned probe answers 401,
|
||||
which Pair reads as a busy card, so a missing token degrades to the resident
|
||||
model rather than breaking a turn. The proxy also
|
||||
which Pair reads as a busy card, but Maven now rejects a live LAN workstation
|
||||
block with a missing token at startup: fallback must describe a temporarily
|
||||
unavailable machine, not hide a broken secret source. `model_disabled: true`
|
||||
is the explicit resident-only state and does not take CW2 down with it. The
|
||||
proxy also
|
||||
allowlists the five paths Maven calls, so a leaked token buys the model API and
|
||||
not llama-server's admin surface.
|
||||
|
||||
|
||||
+18
-2
@@ -67,14 +67,30 @@ Config path: `~/.config/maven/mavend.json`. Full example with all options.
|
||||
|
||||
Omit the `embedder` block entirely to use the deterministic HashEmbedder floor (no ML, no ONNX runtime dependency). Useful for testing or low-resource setups.
|
||||
|
||||
`${NTFY_TOKEN}` and the `${TELEGRAM_*}` vars are expanded from `deploy/telegram.env`, which is gitignored. Copy `deploy/telegram.env.example` and fill it in. Mint a scoped token rather than reusing an admin one. It needs write access to the `maven` topic and nothing else:
|
||||
`deploy/telegram.env.example` is the canonical inventory for every deployed
|
||||
secret, including values whose destination is the root `.env`, the workpc CW2
|
||||
environment file, or `deploy/db_key.env`. Copy values only to the destination
|
||||
named beside them; never commit the populated files. Maven expands the homesrv
|
||||
sink, workstation, and Home Assistant variables from `deploy/telegram.env`.
|
||||
|
||||
Every written integration block is either explicitly disabled or live. A live
|
||||
Telegram, ntfy, LAN workstation model/STT, Home Assistant, ambient, CW2, or
|
||||
encrypted-database configuration with an empty credential fails startup. This
|
||||
keeps a missing env file from quietly becoming fallback behavior. The deployed
|
||||
ntfy is currently `disabled: true`, and the workstation model arm is
|
||||
`model_disabled: true`; the separately credentialed CW2 STT arm remains live.
|
||||
Remove a dark-state flag only after provisioning that arm's credential.
|
||||
|
||||
Mint a scoped ntfy token rather than reusing an admin one. It needs write access
|
||||
to the `maven` topic and nothing else:
|
||||
|
||||
```sh
|
||||
ntfy access maven maven write-only
|
||||
ntfy token add --expires=never maven
|
||||
```
|
||||
|
||||
Deleting the `ntfy` block turns the reach off, and that is not a no-op. The routing table sends sev3-away nudges and away reminders to ntfy and nowhere else. With no sink wired they hit a nil and vanish, leaving no log line and no `delivery_attempts` row (V-649).
|
||||
`disabled: true` keeps a documented ntfy block dark. When enabled, reminders
|
||||
try ntfy and fall through to Telegram; delivery attempts record each reach.
|
||||
|
||||
## mavsttd — STT worker (optional, remote whisper.cpp)
|
||||
|
||||
|
||||
+24
-12
@@ -332,12 +332,11 @@ func Load(path string) (*Config, error) {
|
||||
// file committed to git.
|
||||
expanded, missing := expandEnv(string(b))
|
||||
if len(missing) > 0 {
|
||||
// An unset variable expands to "", which every block reads as "not
|
||||
// configured" and none of them complains about. That is the intended
|
||||
// behaviour and it stays: CI parses this same file with no secrets
|
||||
// present. What was missing is the line telling the operator which
|
||||
// capability he just turned off by forgetting an env file.
|
||||
log.Printf("config: %s references unset environment variables %v — those settings are empty, so whatever they configure is off", path, missing)
|
||||
// Expansion happens before typed validation. A disabled block may carry
|
||||
// empty placeholders; an enabled block that needs one of these values is
|
||||
// rejected below. Log the names too so a failed deploy says which secret
|
||||
// source was absent without ever printing a value.
|
||||
log.Printf("config: %s references unset environment variables %v — expanded them to empty; enabled integrations will reject missing credentials", path, missing)
|
||||
}
|
||||
var c Config
|
||||
if err := json.Unmarshal([]byte(expanded), &c); err != nil {
|
||||
@@ -459,6 +458,12 @@ func (c *Config) validate() error {
|
||||
if err := c.validateTelegram(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.validateNtfy(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.validateWorkstation(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -468,17 +473,24 @@ func (c *Config) validate() error {
|
||||
// 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 {
|
||||
if c.Telegram == nil || c.Telegram.Disabled {
|
||||
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
|
||||
if err := telegramsink.Validate(*c.Telegram); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Telegram.Intake {
|
||||
return telegramsink.ValidateIntakeChatID(c.Telegram.ChatID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) validateNtfy() error {
|
||||
if c.Ntfy == nil {
|
||||
return nil
|
||||
}
|
||||
return ntfysink.Validate(*c.Ntfy)
|
||||
}
|
||||
|
||||
// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins
|
||||
// over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens
|
||||
|
||||
@@ -435,10 +435,21 @@ func TestNormaliseDropsAddresslessWorkstation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelDisabledKeepsLiveSTT(t *testing.T) {
|
||||
p := writeConfig(t, `{"workstation":{"model_disabled":true,"stt":{"url":"http://192.168.1.105:8081/transcribe","token":"secret"}}}`)
|
||||
c, err := Load(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if c.Workstation == nil || c.Workstation.Stt == nil || !c.Workstation.ModelDisabled {
|
||||
t.Fatalf("model-only dark state did not preserve STT: %+v", c.Workstation)
|
||||
}
|
||||
}
|
||||
|
||||
// The health endpoint defaults to the supervisor's, not llama-server's: mavgpud
|
||||
// answers 503 while the card is held, and that refusal is the whole signal.
|
||||
func TestNormaliseFillsWorkstationDefaults(t *testing.T) {
|
||||
c := &Config{Workstation: &WorkstationConfig{URL: "http://192.168.1.105:8080/"}}
|
||||
c := &Config{Workstation: &WorkstationConfig{URL: "http://192.168.1.105:8080/", Token: "secret"}}
|
||||
c.applyDefaults()
|
||||
if c.Workstation == nil {
|
||||
t.Fatal("dropped a usable workstation block")
|
||||
@@ -460,6 +471,7 @@ func TestNormaliseKeepsExplicitWorkstationHealth(t *testing.T) {
|
||||
c := &Config{Workstation: &WorkstationConfig{
|
||||
URL: "http://192.168.1.105:8080",
|
||||
Health: "http://192.168.1.105:9000/ready",
|
||||
Token: "secret",
|
||||
}}
|
||||
c.applyDefaults()
|
||||
if got, want := c.Workstation.Health, "http://192.168.1.105:9000/ready"; got != want {
|
||||
@@ -467,6 +479,112 @@ func TestNormaliseKeepsExplicitWorkstationHealth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkstationLANEndpointsRequireSecrets(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"model", `{"workstation":{"url":"http://192.168.1.105:8080"}}`},
|
||||
{"stt", `{"workstation":{"url":"http://127.0.0.1:8080","stt":{"url":"http://192.168.1.105:8081/transcribe"}}}`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := Load(writeConfig(t, tc.body)); err == nil {
|
||||
t.Fatal("Load accepted an enabled LAN endpoint with no secret")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkstationDisabledAllowsEmptySecrets(t *testing.T) {
|
||||
p := writeConfig(t, `{"workstation":{"disabled":true,"url":"http://192.168.1.105:8080","stt":{"url":"http://192.168.1.105:8081/transcribe"}}}`)
|
||||
c, err := Load(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if c.Workstation != nil {
|
||||
t.Fatalf("disabled workstation survived normalisation: %+v", c.Workstation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkstationArmsMayBeDisabledIndependently(t *testing.T) {
|
||||
modelOff := writeConfig(t, `{"workstation":{"model_disabled":true,"url":"http://192.168.1.105:8080","stt":{"url":"http://127.0.0.1:8081/transcribe"}}}`)
|
||||
if _, err := Load(modelOff); err != nil {
|
||||
t.Fatalf("Load model-disabled config: %v", err)
|
||||
}
|
||||
|
||||
sttOff := writeConfig(t, `{"workstation":{"url":"http://127.0.0.1:8080","stt":{"disabled":true,"url":"http://192.168.1.105:8081/transcribe"}}}`)
|
||||
c, err := Load(sttOff)
|
||||
if err != nil {
|
||||
t.Fatalf("Load STT-disabled config: %v", err)
|
||||
}
|
||||
if c.Workstation == nil || c.Workstation.Stt != nil {
|
||||
t.Fatalf("STT-only dark state changed model arm: %+v", c.Workstation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkstationLoopbackMayRunWithoutSecret(t *testing.T) {
|
||||
p := writeConfig(t, `{"workstation":{"url":"http://127.0.0.1:8080","stt":{"url":"http://localhost:8081/transcribe"}}}`)
|
||||
if _, err := Load(p); err != nil {
|
||||
t.Fatalf("Load loopback development endpoints: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramLiveBlockRequiresBothSecrets(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"telegram":{"chat_id":"42"}}`,
|
||||
`{"telegram":{"bot_token":"token"}}`,
|
||||
} {
|
||||
if _, err := Load(writeConfig(t, body)); err == nil {
|
||||
t.Fatalf("Load accepted enabled Telegram block: %s", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramDisabledAllowsEmptySecrets(t *testing.T) {
|
||||
if _, err := Load(writeConfig(t, `{"telegram":{"disabled":true}}`)); err != nil {
|
||||
t.Fatalf("Load disabled Telegram: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNtfyLiveBlockRequiresSecret(t *testing.T) {
|
||||
p := writeConfig(t, `{"ntfy":{"base_url":"https://ntfy.example","topic":"maven"}}`)
|
||||
if _, err := Load(p); err == nil {
|
||||
t.Fatal("Load accepted enabled ntfy block with no credential")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNtfyDisabledAllowsEmptySecret(t *testing.T) {
|
||||
p := writeConfig(t, `{"ntfy":{"disabled":true,"base_url":"https://ntfy.example","topic":"maven"}}`)
|
||||
if _, err := Load(p); err != nil {
|
||||
t.Fatalf("Load disabled ntfy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmartHomeLiveBlockRequiresSecret(t *testing.T) {
|
||||
p := writeConfig(t, `{"smarthome":{"enabled":true,"provider":"homeassistant","url":"http://192.168.1.50:8123"}}`)
|
||||
if _, err := Load(p); err == nil {
|
||||
t.Fatal("Load accepted enabled Home Assistant block with no token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSmartHomeDisabledAllowsEmptySecret(t *testing.T) {
|
||||
p := writeConfig(t, `{"smarthome":{"enabled":false,"provider":"homeassistant","url":"http://192.168.1.50:8123"}}`)
|
||||
if _, err := Load(p); err != nil {
|
||||
t.Fatalf("Load disabled Home Assistant: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfiguredDBKeyEnvCannotBeEmpty(t *testing.T) {
|
||||
t.Setenv("MAVEN_TEST_DB_KEY", "")
|
||||
c := &Config{DBKeyEnv: "MAVEN_TEST_DB_KEY"}
|
||||
if _, err := c.DBEncryptionKey(); err == nil {
|
||||
t.Fatal("configured empty database key did not fail startup resolution")
|
||||
}
|
||||
if key, err := (&Config{}).DBEncryptionKey(); err != nil || key != nil {
|
||||
t.Fatalf("explicit plaintext config = (%v, %v), want (nil, nil)", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -14,14 +15,20 @@ import (
|
||||
// a config change and a code change have to agree here or the suite is red.
|
||||
//
|
||||
// The ${VAR} expansions come from a gitignored deploy/telegram.env that is not
|
||||
// present in CI. An unset var expands to the empty string, which is exactly the
|
||||
// "not configured" state every block already has to handle, so the parse is
|
||||
// still meaningful without the secrets.
|
||||
// present in CI. Live blocks reject empty secrets; this test supplies inert
|
||||
// Telegram values and verifies that every credential-less block is explicitly
|
||||
// disabled.
|
||||
func TestDeployConfigLoads(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "deploy", "mavend.json")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Skipf("no deploy config at %s: %v", path, err)
|
||||
}
|
||||
// Live blocks fail on expanded-empty credentials. CI provides inert values
|
||||
// so this test exercises the committed shape; explicitly disabled blocks
|
||||
// (ntfy, workstation and Home Assistant) require none.
|
||||
t.Setenv("TELEGRAM_BOT_TOKEN", "test-token")
|
||||
t.Setenv("TELEGRAM_CHAT_ID", "-1001234567890")
|
||||
t.Setenv("MAVEN_STT_TOKEN", "test-token")
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load(%s): %v", path, err)
|
||||
@@ -46,11 +53,10 @@ func TestDeployConfigLoads(t *testing.T) {
|
||||
t.Error("router threshold did not get its default")
|
||||
}
|
||||
|
||||
// The second reach (V-649). Deleting this block is how you turn ntfy off,
|
||||
// so its absence has to be loud: sev3-away nudges and away reminders route
|
||||
// to ntfy and to nothing else, and a nil sink drops them with no log and no
|
||||
// outbox row. The token is a ${VAR} that CI cannot resolve, so this checks
|
||||
// the wiring and not the credential.
|
||||
// The second reach (V-649). The token is a ${VAR} that CI cannot resolve, so
|
||||
// the committed deployment makes the dark state explicit. Removing disabled
|
||||
// without provisioning a credential makes runtime wiring fail startup; it
|
||||
// can never silently publish anonymously.
|
||||
if cfg.Ntfy == nil {
|
||||
t.Fatal("deploy config has no ntfy block — sev3-away and away reminders " +
|
||||
"would have nowhere to land, and would vanish silently rather than fail")
|
||||
@@ -58,4 +64,47 @@ func TestDeployConfigLoads(t *testing.T) {
|
||||
if cfg.Ntfy.BaseURL == "" || cfg.Ntfy.Topic == "" {
|
||||
t.Errorf("ntfy block is incomplete: base_url=%q topic=%q", cfg.Ntfy.BaseURL, cfg.Ntfy.Topic)
|
||||
}
|
||||
if !cfg.Ntfy.Disabled {
|
||||
t.Fatal("deploy ntfy reach has no checked-in credential and must remain explicitly disabled")
|
||||
}
|
||||
if cfg.Workstation == nil || !cfg.Workstation.ModelDisabled || cfg.Workstation.Stt == nil {
|
||||
t.Fatalf("deploy must disable only its uncredentialed model arm and retain authenticated STT: %+v", cfg.Workstation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalDeployEnvExampleNamesEverySecret(t *testing.T) {
|
||||
path := filepath.Join("..", "..", "deploy", "telegram.env.example")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
example := string(b)
|
||||
references := map[string]bool{
|
||||
// These two live outside ${...}: DBKeyEnv names an environment variable
|
||||
// as JSON data, while the separately deployed Python child reads its own
|
||||
// environment directly.
|
||||
"CW2_TOKEN": true,
|
||||
"MAVEN_DB_KEY": true,
|
||||
}
|
||||
for _, source := range []string{
|
||||
filepath.Join("..", "..", "deploy", "mavend.json"),
|
||||
filepath.Join("..", "..", "docker-compose.yml"),
|
||||
} {
|
||||
raw, err := os.ReadFile(source)
|
||||
if err != nil {
|
||||
t.Fatalf("read deploy source %s: %v", source, err)
|
||||
}
|
||||
_ = os.Expand(string(raw), func(expr string) string {
|
||||
// Compose supports ${NAME:-default}; os.Expand deliberately hands the
|
||||
// full braced expression to this callback.
|
||||
name, _, _ := strings.Cut(expr, ":-")
|
||||
references[name] = true
|
||||
return ""
|
||||
})
|
||||
}
|
||||
for name := range references {
|
||||
if !strings.Contains(example, name+"=") {
|
||||
t.Errorf("canonical deploy env example omits %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -18,6 +20,13 @@ import (
|
||||
// Only the prompt crosses the LAN, and the workstation is not "the box". The
|
||||
// rules in CLAUDE.md about what may leave still apply.
|
||||
type WorkstationConfig struct {
|
||||
// Disabled keeps both written workpc arms explicitly dark. ModelDisabled is
|
||||
// the narrower switch: CW2 STT may remain live while the large-model
|
||||
// supervisor has no provisioned client token.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
|
||||
ModelDisabled bool `json:"model_disabled,omitempty"`
|
||||
|
||||
// URL — where mavgpud listens, e.g. "http://192.168.1.105:8080". Empty ⇒
|
||||
// the whole block is normalised to nil and nothing probes anything.
|
||||
URL string `json:"url,omitempty"`
|
||||
@@ -30,8 +39,8 @@ type WorkstationConfig struct {
|
||||
// Token — the bearer credential mavgpud requires, expanded from the
|
||||
// environment like every other secret here. It is what stops anything on
|
||||
// the LAN spending the card, so a URL that is not loopback needs one.
|
||||
// Wrong or missing reads as a workstation that is down, and Maven falls
|
||||
// back to the resident model.
|
||||
// A missing token on a LAN URL fails config validation. Loopback development
|
||||
// endpoints may omit it.
|
||||
Token string `json:"token,omitempty"`
|
||||
|
||||
// Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe.
|
||||
@@ -60,6 +69,10 @@ type WorkstationConfig struct {
|
||||
// the ggml-small.bin homesrv loads
|
||||
// (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md).
|
||||
type WorkstationSttConfig struct {
|
||||
// Disabled keeps the written CW2 endpoint dark without also disabling the
|
||||
// independently authenticated model supervisor.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
|
||||
// URL — the transcribe endpoint, e.g.
|
||||
// "http://192.168.1.105:8081/transcribe". Empty ⇒ the block is normalised
|
||||
// to nil and mavsttd takes every turn.
|
||||
@@ -97,13 +110,22 @@ const (
|
||||
// model: an unconfigured workstation is the default deploy and must be
|
||||
// indistinguishable from today.
|
||||
func (c *Config) normaliseWorkstation() {
|
||||
if c.Workstation != nil && strings.TrimSpace(c.Workstation.URL) == "" {
|
||||
if c.Workstation != nil && c.Workstation.Disabled {
|
||||
c.Workstation = nil
|
||||
}
|
||||
if c.Workstation == nil {
|
||||
return
|
||||
}
|
||||
w := c.Workstation
|
||||
// Preserve the historical empty-block meaning. A live STT sub-block makes
|
||||
// the parent non-empty; in that case omitting the model URL is an error
|
||||
// unless model_disabled states the operator's intent.
|
||||
if !w.ModelDisabled && strings.TrimSpace(w.URL) == "" &&
|
||||
(w.Stt == nil || w.Stt.Disabled || strings.TrimSpace(w.Stt.URL) == "") {
|
||||
c.Workstation = nil
|
||||
return
|
||||
}
|
||||
if !w.ModelDisabled {
|
||||
if strings.TrimSpace(w.Health) == "" {
|
||||
w.Health = strings.TrimRight(w.URL, "/") + "/health"
|
||||
}
|
||||
@@ -113,13 +135,60 @@ func (c *Config) normaliseWorkstation() {
|
||||
if w.Timeout <= 0 {
|
||||
w.Timeout = Duration(DefaultWorkstationTimeout)
|
||||
}
|
||||
}
|
||||
normaliseWorkstationStt(w)
|
||||
if w.ModelDisabled && w.Stt == nil {
|
||||
c.Workstation = nil
|
||||
}
|
||||
}
|
||||
|
||||
// validateWorkstation rejects a live LAN endpoint without its bearer secret.
|
||||
// Loopback remains useful for local development without manufacturing a secret;
|
||||
// malformed or non-HTTP endpoints are rejected before any probe starts.
|
||||
func (c *Config) validateWorkstation() error {
|
||||
if c.Workstation == nil {
|
||||
return nil
|
||||
}
|
||||
w := c.Workstation
|
||||
if !w.ModelDisabled {
|
||||
if err := validateWorkstationEndpoint("workstation.url", w.URL, w.Token); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateWorkstationEndpoint("workstation.health", w.Health, w.Token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if w.Stt != nil {
|
||||
if err := validateWorkstationEndpoint("workstation.stt.url", w.Stt.URL, w.Stt.Token); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateWorkstationEndpoint("workstation.stt.health", w.Stt.Health, w.Stt.Token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWorkstationEndpoint(name, raw, token string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return fmt.Errorf("%s must be an absolute http(s) URL", name)
|
||||
}
|
||||
host := strings.TrimSpace(u.Hostname())
|
||||
loopback := strings.EqualFold(host, "localhost")
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
loopback = ip.IsLoopback()
|
||||
}
|
||||
if !loopback && strings.TrimSpace(token) == "" {
|
||||
return fmt.Errorf("%s is not loopback, so its token is required while enabled", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normaliseWorkstationStt applies the speech-to-text block's defaults. No
|
||||
// address, no remote: mavsttd then takes every utterance, which is today.
|
||||
func normaliseWorkstationStt(w *WorkstationConfig) {
|
||||
if w.Stt != nil && strings.TrimSpace(w.Stt.URL) == "" {
|
||||
if w.Stt != nil && (w.Stt.Disabled || strings.TrimSpace(w.Stt.URL) == "") {
|
||||
w.Stt = nil
|
||||
}
|
||||
if w.Stt == nil {
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
//
|
||||
// - reminders are a SEPARATE class — two delivery paths. reminders bypass
|
||||
// the restraint gate ("wake me 7" fires in quiet hours; that's the point).
|
||||
// snooze still applies. voice when present, ntfy when away. fire once.
|
||||
// snooze still applies. voice when present; when away, try ntfy then
|
||||
// telegram as alternatives and stop after the first success. fire once.
|
||||
//
|
||||
// Architecture mirrors the loop's gather/pure split: the routing table is a
|
||||
// PURE function of (severity, presence); the Dispatcher holds the impure Sinks
|
||||
@@ -43,6 +44,12 @@ import (
|
||||
// to import the voice package.
|
||||
var ErrVoiceNoSession = errors.New("delivery: voice: no live session")
|
||||
|
||||
// ErrPermanent is the class of transport failures that waiting cannot repair:
|
||||
// a revoked credential or an endpoint that refuses this sender. Dispatchers
|
||||
// may still try a different reach for the same message, but the failed reach
|
||||
// must not be put on an automatic retry clock until its configuration changes.
|
||||
var ErrPermanent = errors.New("delivery: permanent failure")
|
||||
|
||||
// Channel — one delivery transport. Drop is an explicit no-op (the routing
|
||||
// table chose to suppress, which is a decision, not a failure — "a missed
|
||||
// water nudge is noise"). a nil Sink for a wired channel is a daemon config
|
||||
@@ -91,8 +98,11 @@ func ChannelsFor(sev loop.Severity, presence store.Bucket) []Channel {
|
||||
|
||||
// ChannelsForReminder — reminders are a SEPARATE class that bypasses the gate.
|
||||
// "wake me 7" fires in quiet hours; that's the point. presence still routes
|
||||
// reachability: voice when present, ntfy when away. fires once — no repeat
|
||||
// (repeat-til-ack is a sev4 ops-hard behavior, not a reminder behavior).
|
||||
// reachability: voice when present, then an ordered ntfy→telegram alternative
|
||||
// chain when away. The dispatcher stops after the first successful alternative,
|
||||
// so a reminder still fires once rather than being broadcast on both channels.
|
||||
// There is no repeat (repeat-til-ack is a sev4 ops-hard behavior, not a reminder
|
||||
// behavior).
|
||||
//
|
||||
// reminders don't carry a Severity — they're user-stated future intent, not
|
||||
// loop-derived insistence. the routing is presence-only: reachability without
|
||||
@@ -100,7 +110,7 @@ func ChannelsFor(sev loop.Severity, presence store.Bucket) []Channel {
|
||||
// per-reminder override, not a table entry.
|
||||
func ChannelsForReminder(presence store.Bucket) []Channel {
|
||||
if presence == store.Away {
|
||||
return []Channel{ChannelNtfy}
|
||||
return []Channel{ChannelNtfy, ChannelTelegram}
|
||||
}
|
||||
return []Channel{ChannelVoice}
|
||||
}
|
||||
|
||||
+128
-51
@@ -22,13 +22,25 @@ type NudgeRecorder interface {
|
||||
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// ReminderCompleter — the seam the store implements. For one-shot reminders:
|
||||
// pending → fired after successful delivery. For recurring reminders (with
|
||||
// cron): reschedule after successful delivery. A failed send does NOT mark or
|
||||
// reschedule it (it stays pending; the next tick re-delivers).
|
||||
// ReminderCompleter — the seam the store implements. Every original represented
|
||||
// by one external delivery is completed in one transaction. That matters for a
|
||||
// collapsed catch-up bundle: partially firing the originals would make the
|
||||
// next tick repeat a presentation that the user already received.
|
||||
type ReminderCompleter interface {
|
||||
MarkReminder(ctx context.Context, id int64, status string) error
|
||||
RescheduleReminder(ctx context.Context, id int64, now time.Time) error
|
||||
CompleteReminderDelivery(ctx context.Context, originals []store.Reminder, now time.Time) error
|
||||
}
|
||||
|
||||
// DurableReminderCompleter closes the successful outbox attempt and advances
|
||||
// every reminder occurrence in one local transaction. The external send and
|
||||
// local commit cannot be one transaction, but the local half must be: a crash
|
||||
// between `attempt=sent` and `reminder=fired` otherwise strands the reminder in
|
||||
// a permanently suppressed state.
|
||||
type DurableReminderCompleter interface {
|
||||
CompleteSuccessfulReminderAttempt(ctx context.Context, attemptID int64, originals []store.Reminder, now time.Time) error
|
||||
}
|
||||
|
||||
type ReminderBlocker interface {
|
||||
BlockReminderDelivery(ctx context.Context, originals []store.Reminder, now time.Time, reason string) error
|
||||
}
|
||||
|
||||
// Outbox — the durable delivery ledger. Begin is recorded BEFORE the external
|
||||
@@ -39,7 +51,7 @@ type ReminderCompleter interface {
|
||||
// disabled (existing send/record behavior, unchanged — test scenarios that
|
||||
// don't care about crash recovery).
|
||||
type Outbox interface {
|
||||
BeginDeliveryAttempt(ctx context.Context, kind, rule string, reminderID int64, channel, bodyHash string, now time.Time) (int64, error)
|
||||
BeginDeliveryAttempt(ctx context.Context, kind, rule string, reminderID int64, deliveryGroup, channel, bodyHash string, now time.Time) (int64, error)
|
||||
CompleteDeliveryAttempt(ctx context.Context, id int64, status string, now time.Time) error
|
||||
}
|
||||
|
||||
@@ -57,11 +69,11 @@ func bodyHash(channel Channel, body string) string {
|
||||
// on one attempt shouldn't block a nudge/reminder actually reaching the user
|
||||
// — but it does mean this attempt can't be reconciled after a crash, so it's
|
||||
// logged. Returns 0 (no-op id) when unrecorded.
|
||||
func (d *Dispatcher) beginOutbox(ctx context.Context, kind, rule string, reminderID int64, channel Channel, body string, now time.Time) int64 {
|
||||
func (d *Dispatcher) beginOutbox(ctx context.Context, kind, rule string, reminderID int64, deliveryGroup string, channel Channel, body string, now time.Time) int64 {
|
||||
if d.cfg.Outbox == nil {
|
||||
return 0
|
||||
}
|
||||
id, err := d.cfg.Outbox.BeginDeliveryAttempt(ctx, kind, rule, reminderID, string(channel), bodyHash(channel, body), now)
|
||||
id, err := d.cfg.Outbox.BeginDeliveryAttempt(ctx, kind, rule, reminderID, deliveryGroup, string(channel), bodyHash(channel, body), now)
|
||||
if err != nil {
|
||||
log.Printf("dispatcher: outbox begin failed (send proceeds untracked): %v", err)
|
||||
return 0
|
||||
@@ -69,16 +81,37 @@ func (d *Dispatcher) beginOutbox(ctx context.Context, kind, rule string, reminde
|
||||
return id
|
||||
}
|
||||
|
||||
// beginReminderOutbox is stricter than the nudge helper above. A reminder may
|
||||
// be retried indefinitely, so sending it without the durable attempt row would
|
||||
// reopen an unobservable duplicate window after a crash. A configured but
|
||||
// unhealthy outbox therefore blocks this transport attempt; a deliberately nil
|
||||
// outbox still supports small isolated test/development wiring.
|
||||
func (d *Dispatcher) beginReminderOutbox(ctx context.Context, reminderID int64, deliveryGroup string, channel Channel, body string, now time.Time) (int64, error) {
|
||||
if d.cfg.Outbox == nil {
|
||||
return 0, nil
|
||||
}
|
||||
id, err := d.cfg.Outbox.BeginDeliveryAttempt(
|
||||
ctx, "reminder", "", reminderID, deliveryGroup,
|
||||
string(channel), bodyHash(channel, body), now,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin reminder delivery attempt: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// completeOutbox records the sink's outcome for a prior beginOutbox call.
|
||||
// id==0 means either tracking is disabled or the begin failed — nothing to
|
||||
// complete either way.
|
||||
func (d *Dispatcher) completeOutbox(ctx context.Context, id int64, status string, now time.Time) {
|
||||
func (d *Dispatcher) completeOutbox(ctx context.Context, id int64, status string, now time.Time) error {
|
||||
if id == 0 || d.cfg.Outbox == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
if err := d.cfg.Outbox.CompleteDeliveryAttempt(ctx, id, status, now); err != nil {
|
||||
log.Printf("dispatcher: outbox complete failed: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PhrasedNudge — the phraser module's output for a nudge. the phraser (the
|
||||
@@ -155,7 +188,7 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
|
||||
// the same afterwards. no nudges row: that table feeds the
|
||||
// ignored_rate signal, and a nudge nobody could see must not
|
||||
// count as ignored.
|
||||
id := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, ch, pn.Summary, now)
|
||||
id := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, "", ch, pn.Summary, now)
|
||||
d.completeOutbox(ctx, id, store.DeliveryDropped, now)
|
||||
log.Printf("dispatcher: dropped %s (sev%d, presence=%s) — routing table suppressed it",
|
||||
c.Rule.Name, c.Severity, c.State.Presence)
|
||||
@@ -176,7 +209,7 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
|
||||
if sink == nil {
|
||||
continue
|
||||
}
|
||||
attemptID := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, ch, messageForChannel(s), now)
|
||||
attemptID := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, "", ch, messageForChannel(s), now)
|
||||
if err := safeSend(ctx, sink, s); err != nil {
|
||||
if errors.Is(err, ErrSinkPanicked) {
|
||||
// one broken sink must not eat the other channels for this
|
||||
@@ -226,14 +259,17 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
|
||||
}
|
||||
|
||||
// DispatchReminder — routes a phrased reminder. reminders bypass the gate and
|
||||
// fire once (pending → fired after successful delivery). voice when present,
|
||||
// ntfy when away. no repeat (reminders fire once). marks the reminder fired
|
||||
// only if at least one channel succeeded — a failed send leaves it pending
|
||||
// for the next tick to re-deliver.
|
||||
// fire once (pending → fired after successful delivery). Voice is preferred
|
||||
// when present; if it has no live session, delivery falls back to the ordered
|
||||
// away alternatives. Away delivery tries ntfy, then telegram, and stops after
|
||||
// the first success. A failed or unwired alternative falls through to the next
|
||||
// one. If every selected alternative fails, the reminder stays pending and an
|
||||
// error is returned for the tick's retry path.
|
||||
func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, now time.Time) ([]Dispatch, error) {
|
||||
rd := pr.Decision
|
||||
channels := ChannelsForReminder(rd.State.Presence)
|
||||
var out []Dispatch
|
||||
var failures []error
|
||||
allPermanent := true
|
||||
for i := 0; i < len(channels); i++ {
|
||||
ch := channels[i]
|
||||
s := Sendable{
|
||||
@@ -245,62 +281,103 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n
|
||||
Ts: now,
|
||||
}
|
||||
s = minimalForAway(s)
|
||||
reminderID, deliveryGroup := reminderDeliveryIdentity(rd.Reminder)
|
||||
attemptID, err := d.beginReminderOutbox(ctx, reminderID, deliveryGroup, ch, messageForChannel(s), now)
|
||||
if err != nil {
|
||||
allPermanent = false
|
||||
failures = append(failures, err)
|
||||
log.Printf("dispatcher: reminder %d delivery via %s withheld: %v", rd.Reminder.ID, ch, err)
|
||||
continue
|
||||
}
|
||||
sink := d.sinkFor(ch)
|
||||
if sink == nil {
|
||||
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
|
||||
err := fmt.Errorf("%s sink is not configured", ch)
|
||||
failures = append(failures, err)
|
||||
log.Printf("dispatcher: reminder %d delivery via %s failed: %v", rd.Reminder.ID, ch, err)
|
||||
if ch == ChannelVoice {
|
||||
channels = ChannelsForReminder(store.Away)
|
||||
failures = nil
|
||||
allPermanent = true
|
||||
i = -1
|
||||
}
|
||||
continue
|
||||
}
|
||||
attemptID := d.beginOutbox(ctx, "reminder", "", rd.Reminder.ID, ch, messageForChannel(s), now)
|
||||
if err := safeSend(ctx, sink, s); err != nil {
|
||||
if errors.Is(err, ErrSinkPanicked) {
|
||||
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
|
||||
continue
|
||||
failures = append(failures, fmt.Errorf("send %s: %w", ch, err))
|
||||
if !errors.Is(err, ErrPermanent) {
|
||||
allPermanent = false
|
||||
}
|
||||
if errors.Is(err, ErrVoiceNoSession) {
|
||||
// presence guess was wrong — reroute reminder to the away
|
||||
// channel (ntfy). voice is the only present channel, so nothing
|
||||
// has been sent yet.
|
||||
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
|
||||
// Presence was stale. Voice is the only present alternative, so
|
||||
// nothing has been sent and it is safe to start the away chain.
|
||||
log.Printf("dispatcher: no live voice session for reminder %d, rerouting to away channels", rd.Reminder.ID)
|
||||
channels = ChannelsForReminder(store.Away)
|
||||
failures = nil
|
||||
allPermanent = true
|
||||
i = -1
|
||||
continue
|
||||
}
|
||||
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
|
||||
return out, fmt.Errorf("send %s: %w", ch, err)
|
||||
log.Printf("dispatcher: reminder %d delivery via %s failed: %v", rd.Reminder.ID, ch, err)
|
||||
continue
|
||||
}
|
||||
d.completeOutbox(ctx, attemptID, store.DeliverySent, now)
|
||||
out = append(out, Dispatch{Sendable: s})
|
||||
}
|
||||
if d.cfg.Reminders != nil && len(out) > 0 {
|
||||
// ID=0 is a synthetic digest reminder; it's not in the DB. Complete
|
||||
// the collapsed originals it stands in for instead — only now, after
|
||||
// a successful send, so a failed digest leaves them all pending.
|
||||
out := []Dispatch{{Sendable: s}}
|
||||
originals := []store.Reminder{rd.Reminder}
|
||||
if rd.Reminder.ID == 0 {
|
||||
for _, orig := range rd.Reminder.Collapsed {
|
||||
if err := d.completeReminder(ctx, orig, now); err != nil {
|
||||
return out, err
|
||||
originals = rd.Reminder.Collapsed
|
||||
}
|
||||
if durable, ok := d.cfg.Reminders.(DurableReminderCompleter); ok && attemptID != 0 {
|
||||
if err := durable.CompleteSuccessfulReminderAttempt(ctx, attemptID, originals, now); err != nil {
|
||||
return out, fmt.Errorf("commit successful reminder delivery: %w", err)
|
||||
}
|
||||
} else if err := d.completeReminder(ctx, rd.Reminder, now); err != nil {
|
||||
return out, err
|
||||
return out, nil
|
||||
}
|
||||
if err := d.completeOutbox(ctx, attemptID, store.DeliverySent, now); err != nil {
|
||||
// The external sink accepted the reminder, but its durable outcome is
|
||||
// ambiguous. Do not complete the reminder row: startup reconciliation
|
||||
// will mark the attempt unknown and DueReminders will hold the exact
|
||||
// occurrence for operator resolution rather than sending a duplicate.
|
||||
return out, fmt.Errorf("record successful reminder delivery: %w", err)
|
||||
}
|
||||
if d.cfg.Reminders != nil {
|
||||
if err := d.cfg.Reminders.CompleteReminderDelivery(ctx, originals, now); err != nil {
|
||||
return out, fmt.Errorf("complete reminder delivery: %w", err)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
if len(failures) == 0 {
|
||||
failures = append(failures, errors.New("no delivery alternatives selected"))
|
||||
allPermanent = false
|
||||
}
|
||||
joined := errors.Join(failures...)
|
||||
if allPermanent && d.cfg.Reminders != nil {
|
||||
originals := []store.Reminder{rd.Reminder}
|
||||
if rd.Reminder.ID == 0 {
|
||||
originals = rd.Reminder.Collapsed
|
||||
}
|
||||
if blocker, ok := d.cfg.Reminders.(ReminderBlocker); ok {
|
||||
if err := blocker.BlockReminderDelivery(ctx, originals, now, joined.Error()); err != nil {
|
||||
return nil, fmt.Errorf("block permanently undeliverable reminder %d: %w", rd.Reminder.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("deliver reminder %d: %w", rd.Reminder.ID, joined)
|
||||
}
|
||||
|
||||
// completeReminder — post-delivery bookkeeping for one reminder: recurring
|
||||
// (cron set) reschedules, one-shot marks fired.
|
||||
func (d *Dispatcher) completeReminder(ctx context.Context, r store.Reminder, now time.Time) error {
|
||||
if r.Cron != "" {
|
||||
if err := d.cfg.Reminders.RescheduleReminder(ctx, r.ID, now); err != nil {
|
||||
return fmt.Errorf("reschedule reminder %d: %w", r.ID, err)
|
||||
// reminderDeliveryIdentity gives the outbox both a human-readable real row id
|
||||
// and the exact occurrence key used for crash suppression. A collapsed digest
|
||||
// has synthetic ID zero, so its first original is the representative; the
|
||||
// shared delivery group still identifies every original atomically.
|
||||
func reminderDeliveryIdentity(r store.Reminder) (int64, string) {
|
||||
if r.ID != 0 {
|
||||
return r.ID, r.DeliveryGroup
|
||||
}
|
||||
return nil
|
||||
if len(r.Collapsed) == 0 {
|
||||
return 0, ""
|
||||
}
|
||||
if err := d.cfg.Reminders.MarkReminder(ctx, r.ID, "fired"); err != nil {
|
||||
return fmt.Errorf("mark reminder %d fired: %w", r.ID, err)
|
||||
}
|
||||
return nil
|
||||
return r.Collapsed[0].ID, r.Collapsed[0].DeliveryGroup
|
||||
}
|
||||
|
||||
// RepeatUnacked — the daemon calls this each tick to re-send un-acked sev4
|
||||
@@ -340,7 +417,7 @@ func (d *Dispatcher) RepeatUnacked(ctx context.Context, keys []string, now time.
|
||||
Ts: now,
|
||||
}
|
||||
s = minimalForAway(s)
|
||||
attemptID := d.beginOutbox(ctx, "nudge", key, 0, ChannelTelegram, messageForChannel(s), now)
|
||||
attemptID := d.beginOutbox(ctx, "nudge", key, 0, "", ChannelTelegram, messageForChannel(s), now)
|
||||
if err := safeSend(ctx, d.cfg.Telegram, s); err != nil {
|
||||
d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now)
|
||||
if errors.Is(err, ErrSinkPanicked) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package delivery
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -61,9 +62,22 @@ type fakeReminderCompleter struct {
|
||||
status string
|
||||
}
|
||||
rescheduled []int64
|
||||
blocked []int64
|
||||
blockReason string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeReminderCompleter) BlockReminderDelivery(_ context.Context, originals []store.Reminder, _ time.Time, reason string) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
for _, r := range originals {
|
||||
f.blocked = append(f.blocked, r.ID)
|
||||
}
|
||||
f.blockReason = reason
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeReminderCompleter) MarkReminder(_ context.Context, id int64, status string) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
@@ -83,6 +97,23 @@ func (f *fakeReminderCompleter) RescheduleReminder(_ context.Context, id int64,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeReminderCompleter) CompleteReminderDelivery(_ context.Context, originals []store.Reminder, _ time.Time) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
for _, r := range originals {
|
||||
if r.Cron != "" {
|
||||
f.rescheduled = append(f.rescheduled, r.ID)
|
||||
continue
|
||||
}
|
||||
f.marked = append(f.marked, struct {
|
||||
id int64
|
||||
status string
|
||||
}{r.ID, store.ReminderFired})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeAck struct {
|
||||
acked map[string]bool
|
||||
lastSent map[string]time.Time
|
||||
@@ -152,10 +183,10 @@ func TestChannelsForReminderPresentVoice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelsForReminderAwayNtfy(t *testing.T) {
|
||||
func TestChannelsForReminderAwayAlternatives(t *testing.T) {
|
||||
got := ChannelsForReminder(store.Away)
|
||||
if len(got) != 1 || got[0] != ChannelNtfy {
|
||||
t.Fatalf("reminder away: want [ntfy], got %v", got)
|
||||
if len(got) != 2 || got[0] != ChannelNtfy || got[1] != ChannelTelegram {
|
||||
t.Fatalf("reminder away: want [ntfy telegram], got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,8 +384,9 @@ func TestDispatchReminderPresentVoice(t *testing.T) {
|
||||
|
||||
func TestDispatchReminderAwayNtfy(t *testing.T) {
|
||||
ntfy := &fakeSink{}
|
||||
telegram := &fakeSink{}
|
||||
rc := &fakeReminderCompleter{}
|
||||
d := NewDispatcher(Config{Ntfy: ntfy, Reminders: rc})
|
||||
d := NewDispatcher(Config{Ntfy: ntfy, Telegram: telegram, Reminders: rc})
|
||||
|
||||
rd := loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 7, Status: "pending"},
|
||||
@@ -369,6 +401,9 @@ func TestDispatchReminderAwayNtfy(t *testing.T) {
|
||||
if len(out) != 1 || out[0].Sendable.Channel != ChannelNtfy {
|
||||
t.Fatalf("want 1 ntfy, got %+v", out)
|
||||
}
|
||||
if len(telegram.sends) != 0 {
|
||||
t.Fatalf("ntfy succeeded; telegram must not receive a duplicate, got %d sends", len(telegram.sends))
|
||||
}
|
||||
// away channel gets summary, not body
|
||||
if ntfy.sends[0].Summary != "wake up" {
|
||||
t.Fatalf("ntfy summary: want 'wake up', got %q", ntfy.sends[0].Summary)
|
||||
@@ -378,6 +413,203 @@ func TestDispatchReminderAwayNtfy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderAwayNtfyFailureFallsBackToTelegram(t *testing.T) {
|
||||
ntfy := &fakeSink{err: errors.New("ntfy unavailable")}
|
||||
telegram := &fakeSink{}
|
||||
rc := &fakeReminderCompleter{}
|
||||
ob := &fakeOutbox{}
|
||||
d := NewDispatcher(Config{Ntfy: ntfy, Telegram: telegram, Reminders: rc, Outbox: ob})
|
||||
|
||||
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
|
||||
Decision: loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 8, Status: "pending"},
|
||||
State: loop.State{Now: refNow(), Presence: store.Away},
|
||||
},
|
||||
Body: "full medication detail", Summary: "take medication",
|
||||
}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
if len(out) != 1 || out[0].Sendable.Channel != ChannelTelegram || len(telegram.sends) != 1 {
|
||||
t.Fatalf("want one telegram fallback, got out=%+v sends=%d", out, len(telegram.sends))
|
||||
}
|
||||
if got := messageForChannel(telegram.sends[0]); got != "take medication" {
|
||||
t.Fatalf("telegram fallback must retain the minimal away body, got %q", got)
|
||||
}
|
||||
if len(rc.marked) != 1 || rc.marked[0].id != 8 || rc.marked[0].status != "fired" {
|
||||
t.Fatalf("successful fallback must complete the reminder, got %+v", rc.marked)
|
||||
}
|
||||
if len(ob.attempts) != 2 || ob.attempts[0].channel != "ntfy" || ob.attempts[0].status != store.DeliveryFailed ||
|
||||
ob.attempts[1].channel != "telegram" || ob.attempts[1].status != store.DeliverySent {
|
||||
t.Fatalf("want ntfy failed then telegram sent attempts, got %+v", ob.attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderAwayNilNtfyFallsBackVisibly(t *testing.T) {
|
||||
telegram := &fakeSink{}
|
||||
rc := &fakeReminderCompleter{}
|
||||
ob := &fakeOutbox{}
|
||||
d := NewDispatcher(Config{Telegram: telegram, Reminders: rc, Outbox: ob})
|
||||
|
||||
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
|
||||
Decision: loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 9, Status: "pending"},
|
||||
State: loop.State{Now: refNow(), Presence: store.Away},
|
||||
},
|
||||
Body: "full detail", Summary: "short reminder",
|
||||
}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
if len(out) != 1 || out[0].Sendable.Channel != ChannelTelegram || len(telegram.sends) != 1 {
|
||||
t.Fatalf("want one telegram fallback, got out=%+v sends=%d", out, len(telegram.sends))
|
||||
}
|
||||
if len(ob.attempts) != 2 || ob.attempts[0].channel != "ntfy" || ob.attempts[0].status != store.DeliveryFailed ||
|
||||
ob.attempts[1].channel != "telegram" || ob.attempts[1].status != store.DeliverySent {
|
||||
t.Fatalf("nil ntfy must leave a failed row before telegram succeeds, got %+v", ob.attempts)
|
||||
}
|
||||
if len(rc.marked) != 1 || rc.marked[0].id != 9 {
|
||||
t.Fatalf("successful fallback must complete the reminder, got %+v", rc.marked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderAwayAllAlternativesFailStaysPending(t *testing.T) {
|
||||
ntfy := &fakeSink{err: errors.New("ntfy unavailable")}
|
||||
telegram := &fakeSink{err: errors.New("telegram unavailable")}
|
||||
rc := &fakeReminderCompleter{}
|
||||
ob := &fakeOutbox{}
|
||||
d := NewDispatcher(Config{Ntfy: ntfy, Telegram: telegram, Reminders: rc, Outbox: ob})
|
||||
|
||||
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
|
||||
Decision: loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 10, Status: "pending"},
|
||||
State: loop.State{Now: refNow(), Presence: store.Away},
|
||||
},
|
||||
Body: "full detail", Summary: "short reminder",
|
||||
}, refNow())
|
||||
if err == nil {
|
||||
t.Fatal("all alternatives failed: want an error")
|
||||
}
|
||||
if len(out) != 0 || len(rc.marked) != 0 || len(rc.rescheduled) != 0 {
|
||||
t.Fatalf("failed reminder must stay pending, got out=%+v marked=%+v rescheduled=%+v", out, rc.marked, rc.rescheduled)
|
||||
}
|
||||
if len(ob.attempts) != 2 || ob.attempts[0].status != store.DeliveryFailed || ob.attempts[1].status != store.DeliveryFailed {
|
||||
t.Fatalf("want two failed attempts, got %+v", ob.attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderAllPermanentAlternativesBlockGroup(t *testing.T) {
|
||||
permanent := fmt.Errorf("%w: credentials rejected", ErrPermanent)
|
||||
ntfy := &fakeSink{err: permanent}
|
||||
telegram := &fakeSink{err: permanent}
|
||||
rc := &fakeReminderCompleter{}
|
||||
d := NewDispatcher(Config{Ntfy: ntfy, Telegram: telegram, Reminders: rc})
|
||||
now := refNow()
|
||||
originals := []store.Reminder{
|
||||
{ID: 21, Status: store.ReminderPending, NextFireTs: now, DeliveryGroup: "reminder:permanent"},
|
||||
{ID: 22, Status: store.ReminderPending, NextFireTs: now, DeliveryGroup: "reminder:permanent"},
|
||||
}
|
||||
|
||||
_, err := d.DispatchReminder(context.Background(), PhrasedReminder{
|
||||
Decision: loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 0, Status: store.ReminderPending, Collapsed: originals},
|
||||
State: loop.State{Now: now, Presence: store.Away},
|
||||
}, Body: "details", Summary: "two reminders",
|
||||
}, now)
|
||||
if err == nil || !errors.Is(err, ErrPermanent) {
|
||||
t.Fatalf("permanent alternatives error = %v", err)
|
||||
}
|
||||
if len(ntfy.sends) != 0 || len(telegram.sends) != 0 {
|
||||
// fakeSink records only successes, so this also asserts neither was
|
||||
// mistaken for a successful delivery.
|
||||
t.Fatalf("permanent failure produced successful sends: ntfy=%d telegram=%d", len(ntfy.sends), len(telegram.sends))
|
||||
}
|
||||
if len(rc.blocked) != 2 || rc.blocked[0] != 21 || rc.blocked[1] != 22 || rc.blockReason == "" {
|
||||
t.Fatalf("permanent bundle was not durably blocked: ids=%v reason=%q", rc.blocked, rc.blockReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderWithUnrecordableOutboxDoesNotSend(t *testing.T) {
|
||||
telegram := &fakeSink{}
|
||||
rc := &fakeReminderCompleter{}
|
||||
ob := &fakeOutbox{beginErr: errors.New("database unavailable")}
|
||||
d := NewDispatcher(Config{Telegram: telegram, Reminders: rc, Outbox: ob})
|
||||
|
||||
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
|
||||
Decision: loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 11, Status: "pending", DeliveryGroup: "reminder:11"},
|
||||
State: loop.State{Now: refNow(), Presence: store.Away},
|
||||
},
|
||||
Body: "full detail", Summary: "short reminder",
|
||||
}, refNow())
|
||||
if err == nil {
|
||||
t.Fatal("unrecordable reminder attempt must be withheld")
|
||||
}
|
||||
if len(out) != 0 || len(telegram.sends) != 0 || len(rc.marked) != 0 {
|
||||
t.Fatalf("unrecordable reminder escaped durable boundary: out=%+v sends=%d completed=%+v", out, len(telegram.sends), rc.marked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderOutboxCompletionAmbiguityDoesNotCompleteOccurrence(t *testing.T) {
|
||||
telegram := &fakeSink{}
|
||||
rc := &fakeReminderCompleter{}
|
||||
ob := &fakeOutbox{completeErr: errors.New("database unavailable after send")}
|
||||
d := NewDispatcher(Config{Telegram: telegram, Reminders: rc, Outbox: ob})
|
||||
|
||||
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
|
||||
Decision: loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 12, Status: store.ReminderPending, DeliveryGroup: "reminder:12"},
|
||||
State: loop.State{Now: refNow(), Presence: store.Away},
|
||||
},
|
||||
Body: "full detail", Summary: "short reminder",
|
||||
}, refNow())
|
||||
if err == nil {
|
||||
t.Fatal("ambiguous outbox completion must surface an error")
|
||||
}
|
||||
if len(out) != 1 || out[0].Sendable.Channel != ChannelTelegram || len(telegram.sends) != 1 {
|
||||
t.Fatalf("the accepted external send must be reported once: out=%+v sends=%d", out, len(telegram.sends))
|
||||
}
|
||||
if len(rc.marked) != 0 || len(rc.rescheduled) != 0 {
|
||||
t.Fatalf("ambiguous accepted send completed the occurrence: marked=%+v rescheduled=%+v", rc.marked, rc.rescheduled)
|
||||
}
|
||||
if len(ob.attempts) != 2 || ob.attempts[1].status != store.DeliveryPending {
|
||||
t.Fatalf("accepted send should remain pending for startup reconciliation: %+v", ob.attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderCollapsedOutboxUsesRealIDAndSharedGroup(t *testing.T) {
|
||||
telegram := &fakeSink{}
|
||||
rc := &fakeReminderCompleter{}
|
||||
ob := &fakeOutbox{}
|
||||
d := NewDispatcher(Config{Telegram: telegram, Reminders: rc, Outbox: ob})
|
||||
now := refNow()
|
||||
originals := []store.Reminder{
|
||||
{ID: 81, Status: store.ReminderPending, NextFireTs: now, DeliveryGroup: "reminder:bundle"},
|
||||
{ID: 82, Status: store.ReminderPending, NextFireTs: now, DeliveryGroup: "reminder:bundle"},
|
||||
}
|
||||
|
||||
if _, err := d.DispatchReminder(context.Background(), PhrasedReminder{
|
||||
Decision: loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 0, Status: store.ReminderPending, Collapsed: originals},
|
||||
State: loop.State{Now: now, Presence: store.Away},
|
||||
},
|
||||
Body: "two reminders", Summary: "two reminders",
|
||||
}, now); err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
if len(ob.attempts) != 2 {
|
||||
t.Fatalf("attempts=%d, want nil-ntfy plus telegram", len(ob.attempts))
|
||||
}
|
||||
for _, a := range ob.attempts {
|
||||
if a.reminderID != 81 || a.deliveryGroup != "reminder:bundle" {
|
||||
t.Fatalf("synthetic ID escaped into outbox: %+v", a)
|
||||
}
|
||||
}
|
||||
if len(rc.marked) != 2 || rc.marked[0].id != 81 || rc.marked[1].id != 82 {
|
||||
t.Fatalf("collapsed completion did not cover originals: %+v", rc.marked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderFailedSendNotMarkedFired(t *testing.T) {
|
||||
// a failed send must not mark the reminder fired — it stays pending for
|
||||
// the next tick to re-deliver. same instinct as "record after success."
|
||||
@@ -486,11 +718,12 @@ func TestDispatchNudgeVoiceNoSessionSev2Drops(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDispatchReminderVoiceNoSessionRoutesNtfy(t *testing.T) {
|
||||
// present reminder → [voice]. voice has no session → away = ntfy.
|
||||
// present reminder → [voice]. voice has no session → away starts at ntfy.
|
||||
voice := &fakeSink{err: ErrVoiceNoSession}
|
||||
ntfy := &fakeSink{}
|
||||
telegram := &fakeSink{}
|
||||
rc := &fakeReminderCompleter{}
|
||||
d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Reminders: rc})
|
||||
d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Telegram: telegram, Reminders: rc})
|
||||
|
||||
rd := loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 99, Status: "pending"},
|
||||
@@ -505,6 +738,9 @@ func TestDispatchReminderVoiceNoSessionRoutesNtfy(t *testing.T) {
|
||||
if len(ntfy.sends) != 1 || len(out) != 1 || out[0].Sendable.Channel != ChannelNtfy {
|
||||
t.Fatalf("reminder voice-no-session: want 1 ntfy, got ntfy=%d out=%+v", len(ntfy.sends), out)
|
||||
}
|
||||
if len(telegram.sends) != 0 {
|
||||
t.Fatalf("successful ntfy fallback must stop before telegram, got %d sends", len(telegram.sends))
|
||||
}
|
||||
if len(rc.marked) != 1 || rc.marked[0].status != "fired" {
|
||||
t.Fatalf("rerouted reminder must be marked fired: %+v", rc.marked)
|
||||
}
|
||||
@@ -641,6 +877,7 @@ func TestDispatchRecurringReminderReschedules(t *testing.T) {
|
||||
type outboxAttempt struct {
|
||||
kind, rule string
|
||||
reminderID int64
|
||||
deliveryGroup string
|
||||
channel, hash string
|
||||
status string
|
||||
begunAt, doneAt time.Time
|
||||
@@ -659,13 +896,13 @@ type fakeOutbox struct {
|
||||
completeErr error
|
||||
}
|
||||
|
||||
func (f *fakeOutbox) BeginDeliveryAttempt(_ context.Context, kind, rule string, reminderID int64, channel, hash string, now time.Time) (int64, error) {
|
||||
func (f *fakeOutbox) BeginDeliveryAttempt(_ context.Context, kind, rule string, reminderID int64, deliveryGroup, channel, hash string, now time.Time) (int64, error) {
|
||||
if f.beginErr != nil {
|
||||
return 0, f.beginErr
|
||||
}
|
||||
f.nextID++
|
||||
f.attempts = append(f.attempts, &outboxAttempt{
|
||||
kind: kind, rule: rule, reminderID: reminderID, channel: channel, hash: hash,
|
||||
kind: kind, rule: rule, reminderID: reminderID, deliveryGroup: deliveryGroup, channel: channel, hash: hash,
|
||||
status: "pending", begunAt: now,
|
||||
})
|
||||
return f.nextID, nil
|
||||
@@ -985,8 +1222,8 @@ func TestPanicInReminderSinkResolvesTheAttempt(t *testing.T) {
|
||||
},
|
||||
Body: "звонок", Summary: "звонок",
|
||||
}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("a panicking sink must not fail the dispatch: %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("a panicking sole reminder sink must report delivery failure")
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Fatalf("nothing was delivered, want no dispatches, got %+v", out)
|
||||
|
||||
@@ -122,7 +122,7 @@ func TestCrashBetweenBeginAndCompleteBecomesUnknown(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
|
||||
// the crash: intent recorded, no completion.
|
||||
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "telegram", "hash", refNow())
|
||||
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "", "telegram", "hash", refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func TestUnknownIsNeverResolvedToSentOrFailed(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "telegram", "hash", refNow())
|
||||
id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "", "telegram", "hash", refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,12 @@ import (
|
||||
// the credential lives in the daemon's config (or a systemd credential),
|
||||
// never in the binary.
|
||||
type Config struct {
|
||||
// Disabled keeps a written endpoint explicitly dark. This is distinct from
|
||||
// an expanded-empty credential: the latter is a configuration error, while
|
||||
// this flag records an operator decision to use another delivery reach until
|
||||
// credentials are provisioned.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
|
||||
// BaseURL — the ntfy server, no trailing path. Required.
|
||||
BaseURL string `json:"base_url"`
|
||||
|
||||
@@ -71,23 +77,45 @@ type Sink struct {
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
// New validates the config and builds the sink. BaseURL and Topic are
|
||||
// required; auth is optional (but deny-all servers reject unauthed publishes).
|
||||
func New(cfg Config) (*Sink, error) {
|
||||
if cfg.BaseURL == "" {
|
||||
return nil, fmt.Errorf("ntfysink: BaseURL is required")
|
||||
// Validate checks one configuration without constructing a client. A disabled
|
||||
// block is the only state in which credentials may be empty. Maven's ntfy
|
||||
// reach is private, and accepting an accidental anonymous configuration turns
|
||||
// a missing environment variable into an endless 403 retry loop.
|
||||
func Validate(cfg Config) error {
|
||||
if cfg.Disabled {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(cfg.BaseURL) == "" {
|
||||
return fmt.Errorf("ntfysink: BaseURL is required while enabled")
|
||||
}
|
||||
if _, err := url.Parse(cfg.BaseURL); err != nil {
|
||||
return nil, fmt.Errorf("ntfysink: bad BaseURL: %w", err)
|
||||
return fmt.Errorf("ntfysink: bad BaseURL: %w", err)
|
||||
}
|
||||
if cfg.Topic == "" {
|
||||
return nil, fmt.Errorf("ntfysink: Topic is required")
|
||||
if strings.TrimSpace(cfg.Topic) == "" {
|
||||
return fmt.Errorf("ntfysink: Topic is required while enabled")
|
||||
}
|
||||
// Refuse rather than pick. Two credentials configured means someone
|
||||
// intended one of them, and guessing which would send the other nowhere
|
||||
// and leave a working config that is not the one they wrote.
|
||||
if cfg.Token != "" && cfg.Username != "" {
|
||||
return nil, fmt.Errorf("ntfysink: set Token or Username, not both")
|
||||
if strings.TrimSpace(cfg.Token) != "" && (strings.TrimSpace(cfg.Username) != "" || cfg.Password != "") {
|
||||
return fmt.Errorf("ntfysink: set Token or Username/Password, not both")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Token) == "" {
|
||||
if strings.TrimSpace(cfg.Username) == "" || cfg.Password == "" {
|
||||
return fmt.Errorf("ntfysink: Token or Username/Password is required while enabled")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// New validates the config and builds the sink. Disabled configs belong at the
|
||||
// daemon wiring boundary and cannot accidentally become live sinks.
|
||||
func New(cfg Config) (*Sink, error) {
|
||||
if cfg.Disabled {
|
||||
return nil, fmt.Errorf("ntfysink: config is disabled")
|
||||
}
|
||||
if err := Validate(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to := cfg.Timeout
|
||||
if to == 0 {
|
||||
@@ -127,6 +155,10 @@ func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return fmt.Errorf("%w: ntfysink: credentials rejected (%d): %s",
|
||||
delivery.ErrPermanent, resp.StatusCode, strings.TrimSpace(string(rb)))
|
||||
}
|
||||
return fmt.Errorf("ntfysink: ntfy returned %d: %s", resp.StatusCode, strings.TrimSpace(string(rb)))
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -2,6 +2,7 @@ package ntfysink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -81,6 +82,10 @@ func reminderSendable(summary string) delivery.Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
func tokenConfig(baseURL string) Config {
|
||||
return Config{BaseURL: baseURL, Topic: "maven", Token: "scoped-write-token"}
|
||||
}
|
||||
|
||||
// ----------------------------- config ---------------------------------------
|
||||
|
||||
func TestNewRejectsEmptyBaseURL(t *testing.T) {
|
||||
@@ -98,7 +103,7 @@ func TestNewRejectsEmptyTopic(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewDefaultTimeout(t *testing.T) {
|
||||
s, err := New(Config{BaseURL: "http://localhost:8085", Topic: "maven"})
|
||||
s, err := New(tokenConfig("http://localhost:8085"))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
@@ -107,6 +112,26 @@ func TestNewDefaultTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsAnonymousPublishing(t *testing.T) {
|
||||
_, err := New(Config{BaseURL: "http://localhost:8085", Topic: "maven"})
|
||||
if err == nil {
|
||||
t.Fatal("New accepted a topic with no credential")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "required while enabled") {
|
||||
t.Fatalf("error should identify the enabled reach: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAllowsEmptyCredentialOnlyWhenDisabled(t *testing.T) {
|
||||
cfg := Config{Disabled: true, BaseURL: "http://localhost:8085", Topic: "maven"}
|
||||
if err := Validate(cfg); err != nil {
|
||||
t.Fatalf("Validate disabled config: %v", err)
|
||||
}
|
||||
if _, err := New(cfg); err == nil {
|
||||
t.Fatal("New built a live sink from a disabled config")
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------- send shape ----------------------------------
|
||||
|
||||
func TestSendPostsToTopicPath(t *testing.T) {
|
||||
@@ -114,7 +139,7 @@ func TestSendPostsToTopicPath(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, err := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, err := New(tokenConfig(srv.URL))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
@@ -137,7 +162,7 @@ func TestSendBodyIsSummaryNotFullBody(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert expiring soon")); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
@@ -155,7 +180,7 @@ func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
s := nudgeSendable(loop.Sev3, "")
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
if err := sink.Send(context.Background(), s); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
@@ -173,7 +198,7 @@ func TestSendNeverSendsAnEmptyMessage(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
s := nudgeSendable(loop.Sev3, "")
|
||||
s.Body = ""
|
||||
s.RuleName = ""
|
||||
@@ -209,18 +234,18 @@ func TestSendSetsBasicAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendNoAuthWhenUsernameEmpty(t *testing.T) {
|
||||
func TestTokenConfigSendsBearerAuth(t *testing.T) {
|
||||
rs := newRecordingServer(t, 200, "")
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
_, _, _, auth, _, _ := rs.snapshot()
|
||||
if auth != "" {
|
||||
t.Fatalf("want no auth header, got %q", auth)
|
||||
if auth != "Bearer scoped-write-token" {
|
||||
t.Fatalf("want bearer auth header, got %q", auth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +285,7 @@ func TestSendTitleIsMaven(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
@@ -277,7 +302,7 @@ func TestPrioritySev3IsHigh(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
_ = sink.Send(context.Background(), nudgeSendable(loop.Sev3, "cert"))
|
||||
_, _, _, _, _, prio := rs.snapshot()
|
||||
if prio != "4" {
|
||||
@@ -290,7 +315,7 @@ func TestPrioritySev4IsMax(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
_ = sink.Send(context.Background(), nudgeSendable(loop.Sev4, "down"))
|
||||
_, _, _, _, _, prio := rs.snapshot()
|
||||
if prio != "5" {
|
||||
@@ -303,7 +328,7 @@ func TestPriorityReminderIsHigh(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
_ = sink.Send(context.Background(), reminderSendable("wake up"))
|
||||
_, _, _, _, _, prio := rs.snapshot()
|
||||
if prio != "4" {
|
||||
@@ -318,7 +343,7 @@ func TestSendReturnsErrorOnNon2xx(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down"))
|
||||
if err == nil {
|
||||
t.Fatal("want error on 403")
|
||||
@@ -326,6 +351,24 @@ func TestSendReturnsErrorOnNon2xx(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "403") {
|
||||
t.Fatalf("error should mention status 403, got: %v", err)
|
||||
}
|
||||
if !errors.Is(err, delivery.ErrPermanent) {
|
||||
t.Fatalf("403 = %v; want delivery.ErrPermanent", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendServerFailureRemainsRetryable(t *testing.T) {
|
||||
rs := newRecordingServer(t, http.StatusServiceUnavailable, `{"error":"temporarily unavailable"}`)
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down"))
|
||||
if err == nil {
|
||||
t.Fatal("want error on 503")
|
||||
}
|
||||
if errors.Is(err, delivery.ErrPermanent) {
|
||||
t.Fatalf("503 was marked permanent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendContextCancelReturnsError(t *testing.T) {
|
||||
@@ -333,7 +376,7 @@ func TestSendContextCancelReturnsError(t *testing.T) {
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||
sink, _ := New(tokenConfig(srv.URL))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond)
|
||||
defer cancel()
|
||||
err := sink.Send(ctx, nudgeSendable(loop.Sev3, "down"))
|
||||
@@ -343,7 +386,9 @@ func TestSendContextCancelReturnsError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSendConnectionRefusedReturnsError(t *testing.T) {
|
||||
sink, _ := New(Config{BaseURL: "http://127.0.0.1:1", Topic: "maven", Timeout: time.Second})
|
||||
cfg := tokenConfig("http://127.0.0.1:1")
|
||||
cfg.Timeout = time.Second
|
||||
sink, _ := New(cfg)
|
||||
err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down"))
|
||||
if err == nil {
|
||||
t.Fatal("want error on connection refused")
|
||||
|
||||
@@ -51,6 +51,11 @@ const DefaultTimeout = 10 * time.Second
|
||||
// config file; the bot token + chat id live in the daemon's config (or a
|
||||
// systemd credential), never in the binary.
|
||||
type Config struct {
|
||||
// Disabled keeps a written Telegram block explicitly dark. A present block
|
||||
// is otherwise live, so an expanded-empty credential is a configuration
|
||||
// error rather than an implicit opt-out.
|
||||
Disabled bool `json:"disabled,omitempty"`
|
||||
|
||||
// BotToken — the telegram bot token from BotFather. required. sent in the
|
||||
// URL path (the only place telegram accepts it), not in the body.
|
||||
BotToken string `json:"bot_token"`
|
||||
@@ -91,27 +96,47 @@ type Sink struct {
|
||||
base string // resolved BaseURL, no trailing slash
|
||||
}
|
||||
|
||||
// New validates the config and builds the sink. BotToken and ChatID are
|
||||
// required; Proxy and BaseURL are optional.
|
||||
func New(cfg Config) (*Sink, error) {
|
||||
if cfg.BotToken == "" {
|
||||
return nil, fmt.Errorf("telegramsink: BotToken is required")
|
||||
// Validate checks one configuration without constructing a client. A disabled
|
||||
// block is valid and intentionally carries no credentials; every live block
|
||||
// must carry both secrets and valid endpoint URLs.
|
||||
func Validate(cfg Config) error {
|
||||
if cfg.Disabled {
|
||||
return nil
|
||||
}
|
||||
if cfg.ChatID == "" {
|
||||
return nil, fmt.Errorf("telegramsink: ChatID is required")
|
||||
if strings.TrimSpace(cfg.BotToken) == "" {
|
||||
return fmt.Errorf("telegramsink: BotToken is required while enabled")
|
||||
}
|
||||
if strings.TrimSpace(cfg.ChatID) == "" {
|
||||
return fmt.Errorf("telegramsink: ChatID is required while enabled")
|
||||
}
|
||||
base := cfg.BaseURL
|
||||
if base == "" {
|
||||
base = DefaultBaseURL
|
||||
}
|
||||
if _, err := url.Parse(base); err != nil {
|
||||
return nil, fmt.Errorf("telegramsink: bad BaseURL: %w", err)
|
||||
return fmt.Errorf("telegramsink: bad BaseURL: %w", err)
|
||||
}
|
||||
if cfg.Proxy != "" {
|
||||
if _, err := url.Parse(cfg.Proxy); err != nil {
|
||||
return nil, fmt.Errorf("telegramsink: bad Proxy: %w", err)
|
||||
return fmt.Errorf("telegramsink: bad Proxy: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// New validates the config and builds the sink. Disabled configs belong at the
|
||||
// wiring boundary and cannot accidentally become live sinks.
|
||||
func New(cfg Config) (*Sink, error) {
|
||||
if cfg.Disabled {
|
||||
return nil, fmt.Errorf("telegramsink: config is disabled")
|
||||
}
|
||||
if err := Validate(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base := cfg.BaseURL
|
||||
if base == "" {
|
||||
base = DefaultBaseURL
|
||||
}
|
||||
to := cfg.Timeout
|
||||
if to == 0 {
|
||||
to = DefaultTimeout
|
||||
@@ -192,7 +217,11 @@ func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
||||
var tr telegramResp
|
||||
jsonErr := json.Unmarshal(rb, &tr)
|
||||
if jsonErr == nil && !tr.Ok {
|
||||
return fmt.Errorf("telegramsink: telegram returned error %d: %s", tr.ErrorCode, strings.TrimSpace(tr.Description))
|
||||
err := fmt.Errorf("telegramsink: telegram returned error %d: %s", tr.ErrorCode, strings.TrimSpace(tr.Description))
|
||||
if tr.ErrorCode == http.StatusUnauthorized || tr.ErrorCode == http.StatusForbidden {
|
||||
return fmt.Errorf("%w: %v", delivery.ErrPermanent, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("telegramsink: telegram returned %d: %s", resp.StatusCode, snippet(rb))
|
||||
|
||||
@@ -106,6 +106,16 @@ func TestNewRejectsEmptyChatID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAllowsEmptySecretsOnlyWhenDisabled(t *testing.T) {
|
||||
cfg := Config{Disabled: true}
|
||||
if err := Validate(cfg); err != nil {
|
||||
t.Fatalf("Validate disabled config: %v", err)
|
||||
}
|
||||
if _, err := New(cfg); err == nil {
|
||||
t.Fatal("New built a live sink from a disabled config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewDefaultTimeout(t *testing.T) {
|
||||
s, err := New(Config{BotToken: "123:abc", ChatID: "42"})
|
||||
if err != nil {
|
||||
@@ -289,6 +299,9 @@ func TestSendReturnsErrorOnTelegramError(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "401") {
|
||||
t.Fatalf("error should mention error_code 401, got: %v", err)
|
||||
}
|
||||
if !errors.Is(err, delivery.ErrPermanent) {
|
||||
t.Fatalf("revoked bot credential must be permanent, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A relay that is up but cannot reach api.telegram.org answers 200 with a page
|
||||
@@ -525,9 +538,9 @@ func TestSendRoutesThroughProxyWhenConfigured(t *testing.T) {
|
||||
// ----------------------------- reminder same shape --------------------------
|
||||
|
||||
func TestReminderSendUsesSamePath(t *testing.T) {
|
||||
// reminders away route to ntfy, not telegram — but if the daemon ever
|
||||
// routes a reminder via telegram (per-reminder override), the sink must
|
||||
// accept KindReminder undamaged. exercises the kind-agnostic contract.
|
||||
// Telegram is the second away alternative for reminders. If ntfy is
|
||||
// unavailable and the dispatcher falls through, the sink must accept
|
||||
// KindReminder undamaged. exercises the kind-agnostic contract.
|
||||
rs := newRecordingServer(t, 200, "")
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
@@ -67,6 +67,7 @@ type DeliveryAttempt struct {
|
||||
Kind string `json:"kind"`
|
||||
Rule string `json:"rule,omitempty"`
|
||||
ReminderID int64 `json:"reminder_id,omitempty"`
|
||||
DeliveryGroup string `json:"delivery_group,omitempty"`
|
||||
Channel string `json:"channel"`
|
||||
Status string `json:"status"`
|
||||
Created time.Time `json:"created"`
|
||||
@@ -92,6 +93,11 @@ type Reminder struct {
|
||||
Payload string `json:"payload"`
|
||||
Status string `json:"status"` // pending|fired|cancelled
|
||||
Cron string `json:"cron"`
|
||||
DeliveryGroup string `json:"delivery_group,omitempty"`
|
||||
DeliveryAttempts int `json:"delivery_attempts,omitempty"`
|
||||
NextAttemptTs time.Time `json:"next_attempt_ts,omitempty"`
|
||||
DeliveryBlockedTs time.Time `json:"delivery_blocked_ts,omitempty"`
|
||||
DeliveryBlockedError string `json:"delivery_blocked_error,omitempty"`
|
||||
}
|
||||
|
||||
// Presence — the read the phraser / delivery modules need to decide channel
|
||||
|
||||
@@ -44,6 +44,7 @@ var mapErrPairs = []struct {
|
||||
var unmappedStoreErrors = map[string]string{
|
||||
"ErrKeyLen": "unlock path — the key never crosses CoreAPI",
|
||||
"ErrDecrypt": "unlock path — the key never crosses CoreAPI",
|
||||
"ErrReminderPhrase": "daemon-internal delivery-state validation; modules neither cache reminder presentations nor branch on this verdict",
|
||||
|
||||
"ErrToolCmd": "write-side validation of an allowlist mutation; the caller is the owner at a step-up, not a module branching on the verdict",
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ func (a *storeAPI) DeliveryAttempts(ctx context.Context, status string, n int) (
|
||||
return mapRows(as, err, func(at store.DeliveryAttempt) DeliveryAttempt {
|
||||
out := DeliveryAttempt{
|
||||
ID: at.ID, Kind: at.Kind, Rule: at.Rule, ReminderID: at.ReminderID,
|
||||
Channel: at.Channel, Status: at.Status, Created: at.Created,
|
||||
DeliveryGroup: at.DeliveryGroup, Channel: at.Channel, Status: at.Status, Created: at.Created,
|
||||
}
|
||||
if at.HasComplete {
|
||||
t := at.Completed
|
||||
@@ -359,6 +359,11 @@ func toReminder(r store.Reminder) Reminder {
|
||||
Payload: r.Payload,
|
||||
Status: r.Status,
|
||||
Cron: r.Cron,
|
||||
DeliveryGroup: r.DeliveryGroup,
|
||||
DeliveryAttempts: r.DeliveryAttempts,
|
||||
NextAttemptTs: r.NextAttemptTs,
|
||||
DeliveryBlockedTs: r.DeliveryBlockedTs,
|
||||
DeliveryBlockedError: r.DeliveryBlockedError,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+56
-9
@@ -12,6 +12,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
@@ -258,16 +259,62 @@ func readFact(ctx context.Context, s *store.Store, key string) (store.Fact, bool
|
||||
}
|
||||
|
||||
// collapseReminders — when multiple reminders are due at once (e.g. after
|
||||
// the daemon was offline), collapse them into a single digest reminder to
|
||||
// avoid a burst of individual notifications. The synthetic digest (ID=0)
|
||||
// carries the originals in Collapsed; the dispatcher completes them (mark
|
||||
// fired / reschedule) only after the digest actually delivers, preserving
|
||||
// the "failed send leaves the reminder pending" invariant.
|
||||
// When 0 or 1 reminders are due, returns them unchanged.
|
||||
// the daemon was offline), collapse them into digest reminders to avoid a
|
||||
// burst of individual notifications. A previously phrased delivery group is
|
||||
// kept intact and separate from newly-due reminders: otherwise one new row
|
||||
// joining a failed bundle would force the old bundle through the model again.
|
||||
// The synthetic digest (ID=0) carries the originals in Collapsed; the
|
||||
// dispatcher completes them only after the digest actually delivers.
|
||||
func collapseReminders(due []store.Reminder) []store.Reminder {
|
||||
if len(due) <= 1 {
|
||||
return due
|
||||
}
|
||||
|
||||
// Every reminder without a delivery group is part of the new bundle for
|
||||
// this tick. Persisted groups each retain their own bundle identity.
|
||||
const ungrouped = "\x00"
|
||||
type reminderGroup struct {
|
||||
key string
|
||||
reminders []store.Reminder
|
||||
}
|
||||
var groups []reminderGroup
|
||||
byKey := make(map[string]int)
|
||||
for _, r := range due {
|
||||
key := r.DeliveryGroup
|
||||
if key == "" {
|
||||
key = ungrouped
|
||||
}
|
||||
i, ok := byKey[key]
|
||||
if !ok {
|
||||
i = len(groups)
|
||||
byKey[key] = i
|
||||
groups = append(groups, reminderGroup{key: key})
|
||||
}
|
||||
groups[i].reminders = append(groups[i].reminders, r)
|
||||
}
|
||||
// DueReminders was ordered globally. Grouping can move rows together, so
|
||||
// restore earliest-first ordering between the resulting deliveries.
|
||||
sort.SliceStable(groups, func(i, j int) bool {
|
||||
a := groups[i].reminders[0]
|
||||
b := groups[j].reminders[0]
|
||||
if a.NextFireTs.Equal(b.NextFireTs) {
|
||||
return a.ID < b.ID
|
||||
}
|
||||
return a.NextFireTs.Before(b.NextFireTs)
|
||||
})
|
||||
|
||||
out := make([]store.Reminder, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
if len(group.reminders) == 1 {
|
||||
out = append(out, group.reminders[0])
|
||||
continue
|
||||
}
|
||||
out = append(out, collapseReminderGroup(group.reminders))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func collapseReminderGroup(due []store.Reminder) store.Reminder {
|
||||
// Build a summary payload.
|
||||
var items []string
|
||||
earliest := due[0].FireTs
|
||||
@@ -294,11 +341,11 @@ func collapseReminders(due []store.Reminder) []store.Reminder {
|
||||
|
||||
// Return a single synthetic digest reminder. ID=0 signals "digest" to
|
||||
// the dispatcher, which completes the Collapsed originals on success.
|
||||
return []store.Reminder{{
|
||||
return store.Reminder{
|
||||
ID: 0,
|
||||
FireTs: earliest,
|
||||
Payload: string(digestPayload),
|
||||
Status: "pending",
|
||||
Status: store.ReminderPending,
|
||||
Collapsed: due,
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,3 +47,36 @@ func TestGatherStateLoadsPrefixFamilies(t *testing.T) {
|
||||
t.Fatal("the rule must fire on a gathered per-monitor fact")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapseRemindersKeepsPersistedBundleSeparateFromNewDueRows(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
|
||||
due := []store.Reminder{
|
||||
{ID: 1, FireTs: now, NextFireTs: now, Payload: "one", DeliveryGroup: "reminder:old", PhraseBody: "old bundle"},
|
||||
{ID: 2, FireTs: now.Add(time.Second), NextFireTs: now.Add(time.Second), Payload: "two", DeliveryGroup: "reminder:old", PhraseBody: "old bundle"},
|
||||
{ID: 3, FireTs: now.Add(2 * time.Second), NextFireTs: now.Add(2 * time.Second), Payload: "three"},
|
||||
}
|
||||
|
||||
got := collapseReminders(due)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("deliveries = %d, want old bundle plus new reminder: %+v", len(got), got)
|
||||
}
|
||||
if got[0].ID != 0 || len(got[0].Collapsed) != 2 || got[0].Collapsed[0].ID != 1 || got[0].Collapsed[1].ID != 2 {
|
||||
t.Fatalf("old persisted bundle was changed: %+v", got[0])
|
||||
}
|
||||
if got[1].ID != 3 || len(got[1].Collapsed) != 0 {
|
||||
t.Fatalf("new reminder joined the old phrase: %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapseRemindersMakesOneBundleForNewDueRows(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
|
||||
due := []store.Reminder{
|
||||
{ID: 1, FireTs: now, NextFireTs: now, Payload: "one"},
|
||||
{ID: 2, FireTs: now, NextFireTs: now, Payload: "two"},
|
||||
{ID: 3, FireTs: now, NextFireTs: now, Payload: "three"},
|
||||
}
|
||||
got := collapseReminders(due)
|
||||
if len(got) != 1 || got[0].ID != 0 || len(got[0].Collapsed) != 3 {
|
||||
t.Fatalf("new reminders did not collapse together: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,16 @@ const (
|
||||
// send happens, so a crash between "sent externally" and "recorded" leaves a
|
||||
// trace instead of silence. kind is "nudge" or "reminder"; rule is set for
|
||||
// nudges, reminderID for reminders (the other left at its zero value).
|
||||
// deliveryGroup is the exact persisted reminder occurrence or collapsed
|
||||
// bundle; it is empty for nudges and legacy reminder attempts.
|
||||
// bodyHash is an opaque caller-computed key (e.g. sha256 of channel+body) —
|
||||
// stored for post-crash operator triage, not enforced as a uniqueness
|
||||
// constraint (a rule/reminder legitimately re-sends across ticks).
|
||||
func (s *Store) BeginDeliveryAttempt(ctx context.Context, kind, rule string, reminderID int64, channel, bodyHash string, now time.Time) (int64, error) {
|
||||
func (s *Store) BeginDeliveryAttempt(ctx context.Context, kind, rule string, reminderID int64, deliveryGroup, channel, bodyHash string, now time.Time) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO delivery_attempts (kind, rule, reminder_id, channel, body_hash, status, created_ts)
|
||||
VALUES (?, ?, ?, ?, ?, 'pending', ?)`,
|
||||
kind, rule, reminderID, channel, bodyHash, now.UnixMilli())
|
||||
`INSERT INTO delivery_attempts (kind, rule, reminder_id, delivery_group, channel, body_hash, status, created_ts)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)`,
|
||||
kind, rule, reminderID, deliveryGroup, channel, bodyHash, now.UnixMilli())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("begin delivery attempt: %w", err)
|
||||
}
|
||||
@@ -94,6 +96,7 @@ type DeliveryAttempt struct {
|
||||
Kind string // nudge|reminder
|
||||
Rule string // set for nudges
|
||||
ReminderID int64 // set for reminders
|
||||
DeliveryGroup string // exact reminder occurrence/bundle; empty for nudges and legacy rows
|
||||
Channel string
|
||||
Status string // one of the Delivery* constants
|
||||
Created time.Time
|
||||
@@ -117,7 +120,7 @@ func (s *Store) ListDeliveryAttempts(ctx context.Context, status string, limit i
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
q := `SELECT id, kind, rule, reminder_id, channel, status, created_ts, completed_ts
|
||||
q := `SELECT id, kind, rule, reminder_id, delivery_group, channel, status, created_ts, completed_ts
|
||||
FROM delivery_attempts`
|
||||
args := []any{}
|
||||
if status != "" {
|
||||
@@ -138,7 +141,7 @@ func (s *Store) ListDeliveryAttempts(ctx context.Context, status string, limit i
|
||||
var a DeliveryAttempt
|
||||
var created int64
|
||||
var completed *int64
|
||||
if err := rows.Scan(&a.ID, &a.Kind, &a.Rule, &a.ReminderID, &a.Channel, &a.Status, &created, &completed); err != nil {
|
||||
if err := rows.Scan(&a.ID, &a.Kind, &a.Rule, &a.ReminderID, &a.DeliveryGroup, &a.Channel, &a.Status, &created, &completed); err != nil {
|
||||
return nil, fmt.Errorf("list delivery attempts: scan: %w", err)
|
||||
}
|
||||
a.Created = time.UnixMilli(created)
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestDroppedDeliveryAttemptRoundTrips(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
id, err := s.BeginDeliveryAttempt(ctx, "nudge", "water", 0, "drop", "abc123", now)
|
||||
id, err := s.BeginDeliveryAttempt(ctx, "nudge", "water", 0, "", "drop", "abc123", now)
|
||||
if err != nil {
|
||||
t.Fatalf("BeginDeliveryAttempt: %v", err)
|
||||
}
|
||||
@@ -40,21 +40,21 @@ func TestListDeliveryAttempts(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
base := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
sent, err := s.BeginDeliveryAttempt(ctx, "nudge", "water", 0, "telegram", "h1", base)
|
||||
sent, err := s.BeginDeliveryAttempt(ctx, "nudge", "water", 0, "", "telegram", "h1", base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CompleteDeliveryAttempt(ctx, sent, DeliverySent, base.Add(time.Second)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dropped, err := s.BeginDeliveryAttempt(ctx, "nudge", "care", 0, "telegram", "h2", base.Add(time.Minute))
|
||||
dropped, err := s.BeginDeliveryAttempt(ctx, "nudge", "care", 0, "", "telegram", "h2", base.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CompleteDeliveryAttempt(ctx, dropped, DeliveryDropped, base.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.BeginDeliveryAttempt(ctx, "reminder", "", 7, "voice", "h3", base.Add(2*time.Minute)); err != nil {
|
||||
if _, err := s.BeginDeliveryAttempt(ctx, "reminder", "", 7, "reminder:test", "voice", "h3", base.Add(2*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -351,6 +351,29 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
encoder_id TEXT NOT NULL DEFAULT ''
|
||||
);`,
|
||||
// #25 — durable reminder delivery state (V-651). A reminder can remain
|
||||
// pending for a long time when every reachable transport is unhealthy. The
|
||||
// phrased text belongs to that delivery occurrence, so retaining it here
|
||||
// avoids spending the resident model again on every retry and across daemon
|
||||
// restarts. next_attempt_ts makes transport failures wait on a bounded
|
||||
// exponential backoff instead of retrying at the tick rate. delivery_group
|
||||
// keeps the originals of a collapsed catch-up bundle together; a reminder
|
||||
// that becomes due later must not make the old bundle get re-phrased.
|
||||
`ALTER TABLE reminders ADD COLUMN delivery_group TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE reminders ADD COLUMN phrase_body TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE reminders ADD COLUMN phrase_summary TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE reminders ADD COLUMN phrase_mood TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE reminders ADD COLUMN delivery_attempts INTEGER NOT NULL DEFAULT 0 CHECK (delivery_attempts >= 0);
|
||||
ALTER TABLE reminders ADD COLUMN next_attempt_ts INTEGER;
|
||||
ALTER TABLE delivery_attempts ADD COLUMN delivery_group TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE reminders ADD COLUMN delivery_blocked_ts INTEGER;
|
||||
ALTER TABLE reminders ADD COLUMN delivery_blocked_error TEXT NOT NULL DEFAULT '';
|
||||
CREATE INDEX IF NOT EXISTS idx_reminders_due
|
||||
ON reminders (next_fire_ts, next_attempt_ts)
|
||||
WHERE status = 'pending';
|
||||
CREATE INDEX IF NOT EXISTS idx_delivery_attempts_reminder_group
|
||||
ON delivery_attempts (delivery_group, status)
|
||||
WHERE kind = 'reminder' AND delivery_group <> '';`,
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
+452
-22
@@ -22,6 +22,20 @@ type Reminder struct {
|
||||
Status string // pending | fired | cancelled
|
||||
Cron string // cron expression, empty for one-shot
|
||||
|
||||
// DeliveryGroup and Phrase* are the durable presentation for this exact
|
||||
// occurrence. A collapsed catch-up bundle stores the same group and phrase
|
||||
// on every original reminder, so a retry (including after restart) says the
|
||||
// same thing without asking the model again. Rescheduling a recurring
|
||||
// reminder clears them for its next occurrence.
|
||||
DeliveryGroup string
|
||||
PhraseBody string
|
||||
PhraseSummary string
|
||||
PhraseMood string
|
||||
DeliveryAttempts int
|
||||
NextAttemptTs time.Time
|
||||
DeliveryBlockedTs time.Time
|
||||
DeliveryBlockedError string
|
||||
|
||||
// Collapsed — set only on a synthetic digest reminder (ID=0): the original
|
||||
// due reminders it stands in for. Not persisted. The dispatcher completes
|
||||
// (marks fired / reschedules) each of these after the digest delivers.
|
||||
@@ -62,18 +76,35 @@ const (
|
||||
ReminderPending = "pending"
|
||||
ReminderFired = "fired"
|
||||
ReminderCancelled = "cancelled"
|
||||
|
||||
// ReminderRetryBase and ReminderRetryMax bound the retry cadence. Attempts
|
||||
// continue indefinitely because a reminder must not disappear during a
|
||||
// long transport outage; only the delay stops growing.
|
||||
ReminderRetryBase = time.Minute
|
||||
ReminderRetryMax = time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
ErrReminderNotFound = errors.New("store: reminder not found")
|
||||
ErrReminderState = errors.New("store: reminder not in a mutable state")
|
||||
ErrReminderPhrase = errors.New("store: reminder delivery phrase invalid")
|
||||
)
|
||||
|
||||
const reminderColumns = `id, created_ts, fire_ts, next_fire_ts, payload, status, cron,
|
||||
delivery_group, phrase_body, phrase_summary, phrase_mood, delivery_attempts, next_attempt_ts,
|
||||
delivery_blocked_ts, delivery_blocked_error`
|
||||
|
||||
func scanReminder(sc scanner) (Reminder, error) {
|
||||
var r Reminder
|
||||
var created, fire, nextFire int64
|
||||
var cron *string
|
||||
if err := sc.Scan(&r.ID, &created, &fire, &nextFire, &r.Payload, &r.Status, &cron); err != nil {
|
||||
var nextAttempt *int64
|
||||
var blocked *int64
|
||||
if err := sc.Scan(
|
||||
&r.ID, &created, &fire, &nextFire, &r.Payload, &r.Status, &cron,
|
||||
&r.DeliveryGroup, &r.PhraseBody, &r.PhraseSummary, &r.PhraseMood,
|
||||
&r.DeliveryAttempts, &nextAttempt, &blocked, &r.DeliveryBlockedError,
|
||||
); err != nil {
|
||||
return Reminder{}, err
|
||||
}
|
||||
r.CreatedTs = time.UnixMilli(created).UTC()
|
||||
@@ -82,6 +113,12 @@ func scanReminder(sc scanner) (Reminder, error) {
|
||||
if cron != nil {
|
||||
r.Cron = *cron
|
||||
}
|
||||
if nextAttempt != nil {
|
||||
r.NextAttemptTs = time.UnixMilli(*nextAttempt).UTC()
|
||||
}
|
||||
if blocked != nil {
|
||||
r.DeliveryBlockedTs = time.UnixMilli(*blocked).UTC()
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
@@ -111,11 +148,20 @@ func (s *Store) CreateReminder(ctx context.Context, fire time.Time, payload, cro
|
||||
// DueReminders returns pending reminders with next_fire_ts <= now, oldest first.
|
||||
// This is the predicate input from the loop side: `next_fire_ts <= now AND status='pending'`.
|
||||
func (s *Store) DueReminders(ctx context.Context, now time.Time) ([]Reminder, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+reminderColumns+`
|
||||
FROM reminders
|
||||
WHERE status = 'pending' AND next_fire_ts <= ?
|
||||
ORDER BY next_fire_ts ASC`, now.UnixMilli())
|
||||
AND (next_attempt_ts IS NULL OR next_attempt_ts <= ?)
|
||||
AND delivery_blocked_ts IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM delivery_attempts AS attempt
|
||||
WHERE attempt.kind = 'reminder'
|
||||
AND attempt.delivery_group = reminders.delivery_group
|
||||
AND reminders.delivery_group <> ''
|
||||
AND attempt.status IN ('pending', 'sent', 'unknown')
|
||||
)
|
||||
ORDER BY next_fire_ts ASC, id ASC`, now.UnixMilli(), now.UnixMilli())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("due reminders: %w", err)
|
||||
}
|
||||
@@ -140,8 +186,7 @@ func (s *Store) DueReminders(ctx context.Context, now time.Time) ([]Reminder, er
|
||||
// for next year stayed on it. Bounding by fire time drops what is out of range
|
||||
// instead of what is old.
|
||||
func (s *Store) PendingReminders(ctx context.Context, from, to time.Time) ([]Reminder, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+reminderColumns+`
|
||||
FROM reminders
|
||||
WHERE status = ? AND next_fire_ts >= ? AND next_fire_ts < ?
|
||||
ORDER BY next_fire_ts ASC, id ASC`,
|
||||
@@ -164,29 +209,42 @@ func (s *Store) PendingReminders(ctx context.Context, from, to time.Time) ([]Rem
|
||||
// MarkReminder sets a reminder's status. Only valid transitions: pending→fired,
|
||||
// pending→cancelled. Anything else is a programming error.
|
||||
func (s *Store) MarkReminder(ctx context.Context, id int64, status string) error {
|
||||
if status != "fired" && status != "cancelled" {
|
||||
if status != ReminderFired && status != ReminderCancelled {
|
||||
return fmt.Errorf("%w: %s", ErrReminderState, status)
|
||||
}
|
||||
// pending → fired|cancelled only.
|
||||
// The old read-then-write transition allowed two callers to both observe
|
||||
// pending and both report success. Keeping the source state in the UPDATE
|
||||
// predicate makes pending → fired|cancelled one atomic contest (V-678).
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
"UPDATE reminders SET status = ? WHERE id = ? AND status = ?",
|
||||
status, id, ReminderPending)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark reminder: rows affected: %w", err)
|
||||
}
|
||||
if n == 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Preserve the public distinction between a missing id and a completed
|
||||
// state without weakening the atomic transition above.
|
||||
var current string
|
||||
err := s.db.QueryRowContext(ctx, "SELECT status FROM reminders WHERE id = ?", id).Scan(¤t)
|
||||
err = s.db.QueryRowContext(ctx, "SELECT status FROM reminders WHERE id = ?", id).Scan(¤t)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrReminderNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current != "pending" {
|
||||
return fmt.Errorf("%w: currently %s", ErrReminderState, current)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = ? WHERE id = ?", status, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListReminders returns the n most recent reminders, newest first.
|
||||
func (s *Store) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+reminderColumns+`
|
||||
FROM reminders ORDER BY created_ts DESC, id DESC LIMIT ?`, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list reminders: %w", err)
|
||||
@@ -203,6 +261,352 @@ func (s *Store) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// HasDeliveryPhrase reports whether this occurrence already has a durable
|
||||
// presentation. Summary may intentionally be empty (the delivery boundary has
|
||||
// a generic privacy-preserving fallback), so Body is the readiness marker.
|
||||
func (r Reminder) HasDeliveryPhrase() bool {
|
||||
return r.DeliveryGroup != "" && r.PhraseBody != ""
|
||||
}
|
||||
|
||||
// ReminderRetryDelay returns the delay after attempt (one-based). It grows
|
||||
// exponentially from one minute and stays at one hour; retries do not stop.
|
||||
func ReminderRetryDelay(attempt int) time.Duration {
|
||||
if attempt <= 1 {
|
||||
return ReminderRetryBase
|
||||
}
|
||||
delay := ReminderRetryBase
|
||||
for i := 1; i < attempt && delay < ReminderRetryMax; i++ {
|
||||
if delay >= ReminderRetryMax/2 {
|
||||
return ReminderRetryMax
|
||||
}
|
||||
delay *= 2
|
||||
}
|
||||
if delay > ReminderRetryMax {
|
||||
return ReminderRetryMax
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
// CacheReminderPhrase stores one presentation on every original represented
|
||||
// by a reminder delivery. For a collapsed bundle, originals contains every
|
||||
// row in Reminder.Collapsed and group is shared across all of them.
|
||||
//
|
||||
// The occurrence timestamp and empty-body predicates keep this from attaching
|
||||
// an old phrase to a recurring reminder's next occurrence or overwriting a
|
||||
// phrase another delivery already claimed. The transaction prevents a partial
|
||||
// bundle cache: after a crash either every original can reconstruct the bundle
|
||||
// or none can.
|
||||
func (s *Store) CacheReminderPhrase(
|
||||
ctx context.Context,
|
||||
originals []Reminder,
|
||||
group, body, summary, mood string,
|
||||
) error {
|
||||
if len(originals) == 0 || group == "" || body == "" {
|
||||
return ErrReminderPhrase
|
||||
}
|
||||
if mood == "" {
|
||||
mood = "neutral"
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cache reminder phrase: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
seen := make(map[int64]struct{}, len(originals))
|
||||
for _, r := range originals {
|
||||
if r.ID <= 0 || r.NextFireTs.IsZero() {
|
||||
return ErrReminderPhrase
|
||||
}
|
||||
if _, ok := seen[r.ID]; ok {
|
||||
return ErrReminderPhrase
|
||||
}
|
||||
seen[r.ID] = struct{}{}
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
UPDATE reminders
|
||||
SET delivery_group = ?, phrase_body = ?, phrase_summary = ?, phrase_mood = ?
|
||||
WHERE id = ? AND status = ? AND next_fire_ts = ? AND phrase_body = ''`,
|
||||
group, body, summary, mood, r.ID, ReminderPending, r.NextFireTs.UnixMilli())
|
||||
if err != nil {
|
||||
return fmt.Errorf("cache reminder phrase %d: %w", r.ID, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cache reminder phrase %d: rows affected: %w", r.ID, err)
|
||||
}
|
||||
if n != 1 {
|
||||
return fmt.Errorf("%w: reminder %d is no longer an unphrased pending occurrence", ErrReminderState, r.ID)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("cache reminder phrase: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScheduleReminderRetry moves every still-pending original into a retry wait.
|
||||
// Terminal rows are skipped: cancellation winning while a send was in flight
|
||||
// must not be resurrected. A recurring row whose occurrence changed is skipped
|
||||
// for the same reason. All remaining originals get their own persisted attempt
|
||||
// count; normally a collapsed bundle keeps those counts in lockstep.
|
||||
func (s *Store) ScheduleReminderRetry(ctx context.Context, originals []Reminder, now time.Time) error {
|
||||
if len(originals) == 0 {
|
||||
return ErrReminderNotFound
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("schedule reminder retry: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
seen := make(map[int64]struct{}, len(originals))
|
||||
for _, expected := range originals {
|
||||
if expected.ID <= 0 || expected.NextFireTs.IsZero() {
|
||||
return ErrReminderNotFound
|
||||
}
|
||||
if _, ok := seen[expected.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[expected.ID] = struct{}{}
|
||||
|
||||
var status string
|
||||
var nextFire int64
|
||||
var attempts int
|
||||
var blocked *int64
|
||||
err := tx.QueryRowContext(ctx,
|
||||
`SELECT status, next_fire_ts, delivery_attempts, delivery_blocked_ts FROM reminders WHERE id = ?`,
|
||||
expected.ID).Scan(&status, &nextFire, &attempts, &blocked)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrReminderNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("schedule reminder retry %d: read: %w", expected.ID, err)
|
||||
}
|
||||
if status != ReminderPending || nextFire != expected.NextFireTs.UnixMilli() || blocked != nil {
|
||||
continue
|
||||
}
|
||||
if expected.DeliveryGroup != "" {
|
||||
var ambiguous int
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM delivery_attempts
|
||||
WHERE kind = 'reminder' AND delivery_group = ?
|
||||
AND status IN ('pending', 'sent', 'unknown')
|
||||
)`, expected.DeliveryGroup).Scan(&ambiguous); err != nil {
|
||||
return fmt.Errorf("schedule reminder retry %d: inspect outbox: %w", expected.ID, err)
|
||||
}
|
||||
if ambiguous != 0 {
|
||||
// The sink may have accepted this presentation. Leave the reminder
|
||||
// pending but ineligible for automatic retry; operator resolution
|
||||
// must not be replaced with a guessed duplicate.
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
attempts++
|
||||
nextAttempt := now.Add(ReminderRetryDelay(attempts)).UnixMilli()
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
UPDATE reminders
|
||||
SET delivery_attempts = ?, next_attempt_ts = ?
|
||||
WHERE id = ? AND status = ? AND next_fire_ts = ? AND delivery_attempts = ?`,
|
||||
attempts, nextAttempt, expected.ID, ReminderPending, nextFire, attempts-1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("schedule reminder retry %d: %w", expected.ID, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("schedule reminder retry %d: rows affected: %w", expected.ID, err)
|
||||
}
|
||||
if n != 1 {
|
||||
return fmt.Errorf("%w: reminder %d changed concurrently", ErrReminderState, expected.ID)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("schedule reminder retry: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BlockReminderDelivery records a permanent transport/configuration refusal on
|
||||
// every original represented by one presentation. Blocked rows stay pending and
|
||||
// visible, but never retry automatically. UnblockReminderDelivery is the
|
||||
// deliberate recovery path after credentials or policy are repaired.
|
||||
func (s *Store) BlockReminderDelivery(ctx context.Context, originals []Reminder, now time.Time, reason string) error {
|
||||
if len(originals) == 0 || strings.TrimSpace(reason) == "" {
|
||||
return ErrReminderState
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("block reminder delivery: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
seen := make(map[int64]struct{}, len(originals))
|
||||
for _, expected := range originals {
|
||||
if expected.ID <= 0 || expected.NextFireTs.IsZero() {
|
||||
return ErrReminderNotFound
|
||||
}
|
||||
if _, ok := seen[expected.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[expected.ID] = struct{}{}
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
UPDATE reminders
|
||||
SET delivery_blocked_ts = ?, delivery_blocked_error = ?, next_attempt_ts = NULL
|
||||
WHERE id = ? AND status = ? AND next_fire_ts = ?`,
|
||||
now.UnixMilli(), reason, expected.ID, ReminderPending, expected.NextFireTs.UnixMilli())
|
||||
if err != nil {
|
||||
return fmt.Errorf("block reminder %d: %w", expected.ID, err)
|
||||
}
|
||||
if err := requireOneReminderRow(res, expected.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("block reminder delivery: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UnblockReminderDelivery(ctx context.Context, id int64) error {
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
UPDATE reminders
|
||||
SET delivery_blocked_ts = NULL, delivery_blocked_error = '', next_attempt_ts = NULL
|
||||
WHERE id = ? AND status = ? AND delivery_blocked_ts IS NOT NULL`, id, ReminderPending)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unblock reminder %d: %w", id, err)
|
||||
}
|
||||
return requireOneReminderRow(res, id)
|
||||
}
|
||||
|
||||
// CompleteReminderDelivery applies the bookkeeping for every original covered
|
||||
// by one successful external send in a single transaction. One-shot reminders
|
||||
// become fired; recurring reminders advance to their next occurrence and shed
|
||||
// the old presentation/retry state. The all-or-nothing boundary prevents a
|
||||
// collapsed digest from becoming half-fired and then being repeated.
|
||||
func (s *Store) CompleteReminderDelivery(ctx context.Context, originals []Reminder, now time.Time) error {
|
||||
return s.completeReminderDeliveryIn(ctx, 0, originals, now, time.Local)
|
||||
}
|
||||
|
||||
// CompleteSuccessfulReminderAttempt atomically closes a definitely successful
|
||||
// external attempt and advances every reminder occurrence it represented. If
|
||||
// this transaction cannot commit, the attempt remains pending; startup then
|
||||
// reconciles it to unknown, and the occurrence is held from automatic replay.
|
||||
func (s *Store) CompleteSuccessfulReminderAttempt(ctx context.Context, attemptID int64, originals []Reminder, now time.Time) error {
|
||||
if attemptID <= 0 {
|
||||
return fmt.Errorf("complete reminder delivery: invalid attempt id %d", attemptID)
|
||||
}
|
||||
return s.completeReminderDeliveryIn(ctx, attemptID, originals, now, time.Local)
|
||||
}
|
||||
|
||||
func (s *Store) completeReminderDeliveryIn(ctx context.Context, attemptID int64, originals []Reminder, now time.Time, loc *time.Location) error {
|
||||
if len(originals) == 0 {
|
||||
return ErrReminderNotFound
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete reminder delivery: begin: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if attemptID != 0 {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE delivery_attempts SET status = ?, completed_ts = ? WHERE id = ? AND status = ?`,
|
||||
DeliverySent, now.UnixMilli(), attemptID, DeliveryPending)
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete reminder delivery attempt %d: %w", attemptID, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete reminder delivery attempt %d: rows affected: %w", attemptID, err)
|
||||
}
|
||||
if n != 1 {
|
||||
return fmt.Errorf("complete reminder delivery attempt %d: not pending", attemptID)
|
||||
}
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{}, len(originals))
|
||||
for _, expected := range originals {
|
||||
if expected.ID <= 0 || expected.NextFireTs.IsZero() {
|
||||
return ErrReminderNotFound
|
||||
}
|
||||
if _, ok := seen[expected.ID]; ok {
|
||||
return fmt.Errorf("%w: duplicate reminder %d in one delivery", ErrReminderState, expected.ID)
|
||||
}
|
||||
seen[expected.ID] = struct{}{}
|
||||
|
||||
row := tx.QueryRowContext(ctx, `SELECT `+reminderColumns+` FROM reminders WHERE id = ?`, expected.ID)
|
||||
current, err := scanReminder(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrReminderNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete reminder %d: read: %w", expected.ID, err)
|
||||
}
|
||||
if current.Status != ReminderPending || !current.NextFireTs.Equal(expected.NextFireTs) {
|
||||
return fmt.Errorf("%w: reminder %d occurrence changed", ErrReminderState, expected.ID)
|
||||
}
|
||||
if expected.DeliveryGroup != "" && current.DeliveryGroup != expected.DeliveryGroup {
|
||||
return fmt.Errorf("%w: reminder %d delivery group changed", ErrReminderState, expected.ID)
|
||||
}
|
||||
|
||||
if current.Cron == "" {
|
||||
if err := completeOneShotReminderTx(ctx, tx, current); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
next, err := nextReminderOccurrence(current, now, loc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if next.IsZero() {
|
||||
if err := completeOneShotReminderTx(ctx, tx, current); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `
|
||||
UPDATE reminders
|
||||
SET next_fire_ts = ?, delivery_group = '', phrase_body = '',
|
||||
phrase_summary = '', phrase_mood = '', delivery_attempts = 0,
|
||||
next_attempt_ts = NULL, delivery_blocked_ts = NULL,
|
||||
delivery_blocked_error = ''
|
||||
WHERE id = ? AND status = ? AND next_fire_ts = ?`,
|
||||
next.UnixMilli(), current.ID, ReminderPending, current.NextFireTs.UnixMilli())
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete recurring reminder %d: %w", current.ID, err)
|
||||
}
|
||||
if err := requireOneReminderRow(res, current.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("complete reminder delivery: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func completeOneShotReminderTx(ctx context.Context, tx *sql.Tx, r Reminder) error {
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE reminders SET status = ? WHERE id = ? AND status = ? AND next_fire_ts = ?`,
|
||||
ReminderFired, r.ID, ReminderPending, r.NextFireTs.UnixMilli())
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete reminder %d: %w", r.ID, err)
|
||||
}
|
||||
return requireOneReminderRow(res, r.ID)
|
||||
}
|
||||
|
||||
func requireOneReminderRow(res sql.Result, id int64) error {
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("complete reminder %d: rows affected: %w", id, err)
|
||||
}
|
||||
if n != 1 {
|
||||
return fmt.Errorf("%w: reminder %d changed concurrently", ErrReminderState, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RescheduleReminder computes the next fire time for a recurring reminder and
|
||||
// updates next_fire_ts. Returns ErrReminderState if the reminder is not
|
||||
// recurring or not pending. If the schedule yields no further fire time at all,
|
||||
@@ -229,8 +633,7 @@ func (s *Store) RescheduleReminder(ctx context.Context, id int64, now time.Time)
|
||||
}
|
||||
|
||||
func (s *Store) rescheduleReminderIn(ctx context.Context, id int64, now time.Time, loc *time.Location) error {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+reminderColumns+`
|
||||
FROM reminders WHERE id = ?`, id)
|
||||
r, err := scanReminder(row)
|
||||
if err != nil {
|
||||
@@ -243,9 +646,38 @@ func (s *Store) rescheduleReminderIn(ctx context.Context, id int64, now time.Tim
|
||||
return fmt.Errorf("%w: currently %s", ErrReminderState, r.Status)
|
||||
}
|
||||
|
||||
next, err := nextReminderOccurrence(r, now, loc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if next.IsZero() {
|
||||
return s.MarkReminder(ctx, id, ReminderFired)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `
|
||||
UPDATE reminders
|
||||
SET next_fire_ts = ?, delivery_group = '', phrase_body = '',
|
||||
phrase_summary = '', phrase_mood = '', delivery_attempts = 0,
|
||||
next_attempt_ts = NULL, delivery_blocked_ts = NULL,
|
||||
delivery_blocked_error = ''
|
||||
WHERE id = ? AND status = ? AND next_fire_ts = ?`,
|
||||
next.UnixMilli(), id, ReminderPending, r.NextFireTs.UnixMilli())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("reschedule reminder: rows affected: %w", err)
|
||||
}
|
||||
if n != 1 {
|
||||
return fmt.Errorf("%w: reminder %d changed concurrently", ErrReminderState, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nextReminderOccurrence(r Reminder, now time.Time, loc *time.Location) (time.Time, error) {
|
||||
sched, err := cron.ParseStandard(r.Cron)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse cron %q: %w", r.Cron, err)
|
||||
return time.Time{}, fmt.Errorf("parse cron %q: %w", r.Cron, err)
|
||||
}
|
||||
// Next is strictly after the time it is given, so the last fire cannot be
|
||||
// returned again and no fudge minute is needed. The bound stops a schedule
|
||||
@@ -255,9 +687,7 @@ func (s *Store) rescheduleReminderIn(ctx context.Context, id int64, now time.Tim
|
||||
next = sched.Next(next)
|
||||
}
|
||||
if next.IsZero() || !next.After(now) {
|
||||
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET status = 'fired' WHERE id = ?", id)
|
||||
return err
|
||||
return time.Time{}, nil
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, "UPDATE reminders SET next_fire_ts = ? WHERE id = ?", next.UnixMilli(), id)
|
||||
return err
|
||||
return next, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReminderPhraseAndRetrySurviveRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "reminders.db")
|
||||
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
|
||||
|
||||
s, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := s.CreateReminder(ctx, now.Add(-time.Minute), `{"text":"позвонить маме"}`, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
due, err := s.DueReminders(ctx, now)
|
||||
if err != nil || len(due) != 1 {
|
||||
t.Fatalf("initial due reminders = %d, err=%v", len(due), err)
|
||||
}
|
||||
if err := s.CacheReminderPhrase(ctx, due, "reminder:one", "Позвони маме.", "Позвони маме", "warm"); err != nil {
|
||||
t.Fatalf("cache phrase: %v", err)
|
||||
}
|
||||
if err := s.ScheduleReminderRetry(ctx, due, now); err != nil {
|
||||
t.Fatalf("schedule retry: %v", err)
|
||||
}
|
||||
if err := s.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s, err = Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
if got, err := s.DueReminders(ctx, now.Add(30*time.Second)); err != nil || len(got) != 0 {
|
||||
t.Fatalf("during retry wait: got %d due, err=%v", len(got), err)
|
||||
}
|
||||
due, err = s.DueReminders(ctx, now.Add(ReminderRetryBase))
|
||||
if err != nil || len(due) != 1 {
|
||||
t.Fatalf("at retry time: got %d due, err=%v", len(due), err)
|
||||
}
|
||||
r := due[0]
|
||||
if r.ID != id || r.DeliveryGroup != "reminder:one" || r.PhraseBody != "Позвони маме." ||
|
||||
r.PhraseSummary != "Позвони маме" || r.PhraseMood != "warm" {
|
||||
t.Fatalf("persisted delivery presentation = %+v", r)
|
||||
}
|
||||
if r.DeliveryAttempts != 1 {
|
||||
t.Fatalf("attempts = %d, want 1", r.DeliveryAttempts)
|
||||
}
|
||||
if want := now.Add(ReminderRetryBase); !r.NextAttemptTs.Equal(want) {
|
||||
t.Fatalf("next attempt = %s, want %s", r.NextAttemptTs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderRetryDelayIsExponentialAndBounded(t *testing.T) {
|
||||
wants := []time.Duration{
|
||||
time.Minute, 2 * time.Minute, 4 * time.Minute, 8 * time.Minute,
|
||||
16 * time.Minute, 32 * time.Minute, time.Hour, time.Hour, time.Hour,
|
||||
}
|
||||
for i, want := range wants {
|
||||
if got := ReminderRetryDelay(i + 1); got != want {
|
||||
t.Errorf("attempt %d delay = %s, want %s", i+1, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapsedReminderRetryStateMovesTogether(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
|
||||
for _, text := range []string{"полить цветы", "позвонить врачу"} {
|
||||
if _, err := s.CreateReminder(ctx, now.Add(-time.Minute), text, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
originals, err := s.DueReminders(ctx, now)
|
||||
if err != nil || len(originals) != 2 {
|
||||
t.Fatalf("due originals = %d, err=%v", len(originals), err)
|
||||
}
|
||||
if err := s.CacheReminderPhrase(ctx, originals, "reminder:bundle", "У тебя два напоминания.", "Два напоминания", "neutral"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ScheduleReminderRetry(ctx, originals, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := s.ListReminders(ctx, 10)
|
||||
if err != nil || len(rows) != 2 {
|
||||
t.Fatalf("list = %d, err=%v", len(rows), err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.DeliveryGroup != "reminder:bundle" || r.PhraseBody != "У тебя два напоминания." || r.DeliveryAttempts != 1 {
|
||||
t.Errorf("bundle original did not move with group: %+v", r)
|
||||
}
|
||||
if !r.NextAttemptTs.Equal(now.Add(time.Minute)) {
|
||||
t.Errorf("next attempt = %s, want %s", r.NextAttemptTs, now.Add(time.Minute))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecurringReminderClearsDeliveryStateForNextOccurrence(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
loc := time.FixedZone("MSK", 3*60*60)
|
||||
fire := time.Date(2026, 8, 13, 9, 0, 0, 0, loc)
|
||||
id, err := s.CreateReminder(ctx, fire, `{"text":"стендап"}`, "0 9 * * *")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
due, err := s.DueReminders(ctx, fire)
|
||||
if err != nil || len(due) != 1 {
|
||||
t.Fatalf("due = %d, err=%v", len(due), err)
|
||||
}
|
||||
if err := s.CacheReminderPhrase(ctx, due, "reminder:occurrence", "Пора на стендап.", "Стендап", "neutral"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ScheduleReminderRetry(ctx, due, fire); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.rescheduleReminderIn(ctx, id, fire.Add(time.Minute), loc); err != nil {
|
||||
t.Fatalf("reschedule: %v", err)
|
||||
}
|
||||
|
||||
rows, err := s.ListReminders(ctx, 1)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("list = %d, err=%v", len(rows), err)
|
||||
}
|
||||
r := rows[0]
|
||||
if r.Status != ReminderPending || r.DeliveryGroup != "" || r.PhraseBody != "" ||
|
||||
r.PhraseSummary != "" || r.PhraseMood != "" || r.DeliveryAttempts != 0 || !r.NextAttemptTs.IsZero() {
|
||||
t.Fatalf("next occurrence retained old delivery state: %+v", r)
|
||||
}
|
||||
want := time.Date(2026, 8, 14, 9, 0, 0, 0, loc)
|
||||
if !r.NextFireTs.Equal(want) {
|
||||
t.Fatalf("next fire = %s, want %s", r.NextFireTs.In(loc), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderTerminalTransitionHasExactlyOneWinner(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
|
||||
|
||||
for iteration := 0; iteration < 40; iteration++ {
|
||||
id, err := s.CreateReminder(ctx, now, "race", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 2)
|
||||
statuses := []string{ReminderFired, ReminderCancelled}
|
||||
for i := range statuses {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
errs[i] = s.MarkReminder(ctx, id, statuses[i])
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
successes := 0
|
||||
losers := 0
|
||||
winner := ""
|
||||
for i, err := range errs {
|
||||
switch {
|
||||
case err == nil:
|
||||
successes++
|
||||
winner = statuses[i]
|
||||
case errors.Is(err, ErrReminderState):
|
||||
losers++
|
||||
default:
|
||||
t.Fatalf("iteration %d transition %s: %v", iteration, statuses[i], err)
|
||||
}
|
||||
}
|
||||
if successes != 1 || losers != 1 {
|
||||
t.Fatalf("iteration %d: successes=%d losers=%d errors=%v", iteration, successes, losers, errs)
|
||||
}
|
||||
var stored string
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT status FROM reminders WHERE id = ?`, id).Scan(&stored); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored != winner {
|
||||
t.Fatalf("iteration %d: stored %q, successful transition %q", iteration, stored, winner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAmbiguousCollapsedDeliveryIsNotAutomaticallyRepeated(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
|
||||
for _, text := range []string{"полить цветы", "позвонить врачу"} {
|
||||
if _, err := s.CreateReminder(ctx, now.Add(-time.Minute), text, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
originals, err := s.DueReminders(ctx, now)
|
||||
if err != nil || len(originals) != 2 {
|
||||
t.Fatalf("due originals = %d, err=%v", len(originals), err)
|
||||
}
|
||||
const group = "reminder:ambiguous-bundle"
|
||||
if err := s.CacheReminderPhrase(ctx, originals, group, "У тебя два напоминания.", "Два напоминания", "neutral"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range originals {
|
||||
originals[i].DeliveryGroup = group
|
||||
}
|
||||
|
||||
// Simulate a process dying after Begin and before it can record whether the
|
||||
// external sink accepted the bundle. Synthetic reminder zero is never put
|
||||
// in the outbox: a real original is the human-readable representative and
|
||||
// the group is the occurrence identity shared by both originals.
|
||||
attemptID, err := s.BeginDeliveryAttempt(ctx, "reminder", "", originals[0].ID, group, "telegram", "hash", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.ReconcileStaleDeliveryAttempts(ctx, now.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if due, err := s.DueReminders(ctx, now.Add(24*time.Hour)); err != nil || len(due) != 0 {
|
||||
t.Fatalf("ambiguous delivered bundle was eligible for automatic repeat: due=%+v err=%v", due, err)
|
||||
}
|
||||
attempts, err := s.ListDeliveryAttempts(ctx, "", 10)
|
||||
if err != nil || len(attempts) != 1 {
|
||||
t.Fatalf("attempts=%+v err=%v", attempts, err)
|
||||
}
|
||||
if attempts[0].ID != attemptID || attempts[0].Status != DeliveryUnknown || attempts[0].DeliveryGroup != group {
|
||||
t.Fatalf("ambiguous occurrence lost its durable identity: %+v", attempts[0])
|
||||
}
|
||||
if err := s.ScheduleReminderRetry(ctx, originals, now.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := s.ListReminders(ctx, 10)
|
||||
if err != nil || len(rows) != 2 {
|
||||
t.Fatalf("rows=%+v err=%v", rows, err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.DeliveryAttempts != 0 || !r.NextAttemptTs.IsZero() {
|
||||
t.Fatalf("ambiguous occurrence was scheduled for automatic retry: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefiniteFailedDeliveryRemainsRetryEligible(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
|
||||
if _, err := s.CreateReminder(ctx, now.Add(-time.Minute), "позвонить врачу", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originals, err := s.DueReminders(ctx, now)
|
||||
if err != nil || len(originals) != 1 {
|
||||
t.Fatalf("due originals = %d, err=%v", len(originals), err)
|
||||
}
|
||||
const group = "reminder:definite-failure"
|
||||
if err := s.CacheReminderPhrase(ctx, originals, group, "Позвони врачу.", "Позвони врачу", "neutral"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := s.BeginDeliveryAttempt(ctx, "reminder", "", originals[0].ID, group, "telegram", "hash", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CompleteDeliveryAttempt(ctx, id, DeliveryFailed, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if due, err := s.DueReminders(ctx, now); err != nil || len(due) != 1 {
|
||||
t.Fatalf("definite failure was not retry eligible: due=%+v err=%v", due, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollapsedCompletionRollsBackAsOneTransaction(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
|
||||
for _, text := range []string{"first", "fail-second"} {
|
||||
if _, err := s.CreateReminder(ctx, now.Add(-time.Minute), text, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
originals, err := s.DueReminders(ctx, now)
|
||||
if err != nil || len(originals) != 2 {
|
||||
t.Fatalf("due originals = %d, err=%v", len(originals), err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
CREATE TRIGGER fail_second_reminder_completion
|
||||
BEFORE UPDATE OF status ON reminders
|
||||
WHEN OLD.payload = 'fail-second'
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'injected second completion failure');
|
||||
END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CompleteReminderDelivery(ctx, originals, now); err == nil {
|
||||
t.Fatal("injected second-row failure did not fail the bundle completion")
|
||||
}
|
||||
rows, err := s.ListReminders(ctx, 10)
|
||||
if err != nil || len(rows) != 2 {
|
||||
t.Fatalf("rows=%+v err=%v", rows, err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Status != ReminderPending {
|
||||
t.Fatalf("bundle completion partially committed reminder %d as %q", r.ID, r.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessfulAttemptAndCollapsedCompletionCommitAtomically(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
|
||||
for _, text := range []string{"first", "fail-second"} {
|
||||
if _, err := s.CreateReminder(ctx, now.Add(-time.Minute), text, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
originals, err := s.DueReminders(ctx, now)
|
||||
if err != nil || len(originals) != 2 {
|
||||
t.Fatalf("due originals = %d, err=%v", len(originals), err)
|
||||
}
|
||||
const group = "reminder:atomic-success"
|
||||
if err := s.CacheReminderPhrase(ctx, originals, group, "Two reminders.", "Two reminders", "neutral"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range originals {
|
||||
originals[i].DeliveryGroup = group
|
||||
}
|
||||
attemptID, err := s.BeginDeliveryAttempt(ctx, "reminder", "", originals[0].ID, group, "telegram", "hash", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
CREATE TRIGGER fail_atomic_second_completion
|
||||
BEFORE UPDATE OF status ON reminders
|
||||
WHEN OLD.payload = 'fail-second'
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'injected second completion failure');
|
||||
END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CompleteSuccessfulReminderAttempt(ctx, attemptID, originals, now); err == nil {
|
||||
t.Fatal("injected reminder completion failure did not fail the local commit")
|
||||
}
|
||||
attempts, err := s.ListDeliveryAttempts(ctx, "", 10)
|
||||
if err != nil || len(attempts) != 1 || attempts[0].Status != DeliveryPending {
|
||||
t.Fatalf("successful outbox marker committed without reminder state: attempts=%+v err=%v", attempts, err)
|
||||
}
|
||||
rows, err := s.ListReminders(ctx, 10)
|
||||
if err != nil || len(rows) != 2 {
|
||||
t.Fatalf("rows=%+v err=%v", rows, err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Status != ReminderPending {
|
||||
t.Fatalf("atomic rollback partially completed reminder %d as %q", r.ID, r.Status)
|
||||
}
|
||||
}
|
||||
if _, err := s.ReconcileStaleDeliveryAttempts(ctx, now.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if due, err := s.DueReminders(ctx, now.Add(24*time.Hour)); err != nil || len(due) != 0 {
|
||||
t.Fatalf("restart auto-replayed ambiguous accepted send: due=%+v err=%v", due, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermanentReminderFailureBlocksUntilDeliberatelyUnblocked(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
|
||||
id, err := s.CreateReminder(ctx, now.Add(-time.Minute), "позвонить врачу", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originals, err := s.DueReminders(ctx, now)
|
||||
if err != nil || len(originals) != 1 {
|
||||
t.Fatalf("due originals=%+v err=%v", originals, err)
|
||||
}
|
||||
const group = "reminder:revoked-token"
|
||||
if err := s.CacheReminderPhrase(ctx, originals, group, "Позвони врачу.", "Позвони врачу", "neutral"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originals[0].DeliveryGroup = group
|
||||
if err := s.BlockReminderDelivery(ctx, originals, now, "ntfy credentials rejected"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ScheduleReminderRetry(ctx, originals, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if due, err := s.DueReminders(ctx, now.Add(24*time.Hour)); err != nil || len(due) != 0 {
|
||||
t.Fatalf("blocked reminder retried automatically: due=%+v err=%v", due, err)
|
||||
}
|
||||
rows, err := s.ListReminders(ctx, 1)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("rows=%+v err=%v", rows, err)
|
||||
}
|
||||
if rows[0].DeliveryBlockedTs.IsZero() || rows[0].DeliveryBlockedError != "ntfy credentials rejected" ||
|
||||
rows[0].DeliveryAttempts != 0 || !rows[0].NextAttemptTs.IsZero() {
|
||||
t.Fatalf("blocked state is not visible/durable: %+v", rows[0])
|
||||
}
|
||||
if err := s.UnblockReminderDelivery(ctx, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
due, err := s.DueReminders(ctx, now)
|
||||
if err != nil || len(due) != 1 || due[0].ID != id || !due[0].DeliveryBlockedTs.IsZero() || due[0].DeliveryBlockedError != "" {
|
||||
t.Fatalf("deliberate unblock did not restore eligibility: due=%+v err=%v", due, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user