items 5-7: passkey step-up, tools enable/disable, note RAG — end to end
Completes the three in-flight open items and fixes the away-fallthrough bug. Item 7 — passkey step-up (WebAuthn): - internal/webauthn: ES256/P-256 register + assert with real ecdsa signature verification, minimal CBOR/COSE decode, PasskeySession (L2→L3 on assert, decays after TTL). Drop the RS256 offer we can't verify (register-ok/ assert-fail trap). Verify rpIdHash + UP/UV flags in FinishAssertion — UV is the step-up gesture. Round-trip test with negative cases (tampered sig, missing UV, wrong origin). - cmd/mavweb: /auth/passkey enroll+assert page (the only surface that can do a WebAuthn gesture) + the four begin/finish endpoints. Without this the daemon's PasskeySession swap leaves /tools enable permanently blocked. - daemon wires PasskeySession as the auth Session + srv.StepUp; policy gates MethodAssertStepUp at AuthRead. Item 5 — tools page: DisableTool through store/ipc/client/wire; /tools grows a disable action and a link to the passkey page. Lifecycle test. Item 6 — note RAG: PhraseQuery on the phraser (LLM-composed answer over top-k notes, raw-notes fallback); IntentQuery routes through it. Stub returns a deterministic summary. Item 2 — away-fallthrough: on ErrVoiceNoSession the dispatcher now reroutes through the AWAY table (sev3→ntfy, sev4→telegram-repeat-til-ack, sev≤2→drop) instead of silently dropping / mis-routing to the present-list remainder. Covers DispatchNudge + DispatchReminder. 4 tests. Also: re-add ProposeTool to CoreAPI (dropped in a comment rewrite), fix missing imports + a duplicate block left mid-edit, drop dead AssertStepUpFunc, gitignore /mavcaldav. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// NudgeRecorder — the seam the store implements. the dispatcher records one
|
||||
@@ -87,7 +88,8 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
|
||||
c := pn.Candidate
|
||||
channels := ChannelsFor(c.Severity, c.State.Presence)
|
||||
var out []Dispatch
|
||||
for _, ch := range channels {
|
||||
for i := 0; i < len(channels); i++ {
|
||||
ch := channels[i]
|
||||
if ch == ChannelDrop {
|
||||
continue
|
||||
}
|
||||
@@ -107,7 +109,16 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
|
||||
}
|
||||
if err := sink.Send(ctx, s); err != nil {
|
||||
if errors.Is(err, ErrVoiceNoSession) {
|
||||
log.Printf("dispatcher: no live voice session for %s, falling through", c.Rule.Name)
|
||||
// voice was assumed reachable (presence=present) but no live
|
||||
// session exists — the presence guess was wrong. reroute through
|
||||
// the AWAY table per § away-channel fallthrough: sev3→ntfy,
|
||||
// sev4→telegram-repeat-til-ack, sev≤2→drop. voice is always the
|
||||
// first present channel, so nothing has been sent yet; replace
|
||||
// the remaining list wholesale. away channels never include
|
||||
// voice, so this can't re-trigger.
|
||||
log.Printf("dispatcher: no live voice session for %s, rerouting to away channels", c.Rule.Name)
|
||||
channels = ChannelsFor(c.Severity, store.Away)
|
||||
i = -1
|
||||
continue
|
||||
}
|
||||
return out, fmt.Errorf("send %s: %w", ch, err)
|
||||
@@ -143,7 +154,8 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n
|
||||
rd := pr.Decision
|
||||
channels := ChannelsForReminder(rd.State.Presence)
|
||||
var out []Dispatch
|
||||
for _, ch := range channels {
|
||||
for i := 0; i < len(channels); i++ {
|
||||
ch := channels[i]
|
||||
s := Sendable{
|
||||
Channel: ch,
|
||||
Kind: KindReminder,
|
||||
@@ -158,7 +170,12 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n
|
||||
}
|
||||
if err := sink.Send(ctx, s); err != nil {
|
||||
if errors.Is(err, ErrVoiceNoSession) {
|
||||
log.Printf("dispatcher: no live voice session for reminder %d, falling through", rd.Reminder.ID)
|
||||
// presence guess was wrong — reroute reminder to the away
|
||||
// channel (ntfy). voice is the only present channel, so nothing
|
||||
// has been sent yet.
|
||||
log.Printf("dispatcher: no live voice session for reminder %d, rerouting to away channels", rd.Reminder.ID)
|
||||
channels = ChannelsForReminder(store.Away)
|
||||
i = -1
|
||||
continue
|
||||
}
|
||||
return out, fmt.Errorf("send %s: %w", ch, err)
|
||||
|
||||
@@ -391,6 +391,116 @@ func TestDispatchReminderFailedSendNotMarkedFired(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------ voice no-session → away-channel reroute -----------------
|
||||
//
|
||||
// When the routing table picks voice (presence=present) but no live session
|
||||
// exists at push time, the presence guess was wrong. The dispatcher must
|
||||
// reroute through the AWAY table (§ away-channel fallthrough), not silently
|
||||
// drop or fall to the wrong channel.
|
||||
|
||||
func TestDispatchNudgeVoiceNoSessionSev3RoutesNtfy(t *testing.T) {
|
||||
// present sev3 → [voice]. voice has no session → away sev3 = ntfy.
|
||||
voice := &fakeSink{err: ErrVoiceNoSession}
|
||||
ntfy := &fakeSink{}
|
||||
telegram := &fakeSink{}
|
||||
rec := &fakeNudgeRecorder{}
|
||||
d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Telegram: telegram, Nudges: rec})
|
||||
|
||||
out, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||
Candidate: candidate("cert_expiring", loop.Sev3, store.Present),
|
||||
Body: "cert expiring", Summary: "cert expiring",
|
||||
}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
if len(ntfy.sends) != 1 {
|
||||
t.Fatalf("sev3 voice-no-session: want 1 ntfy send, got %d", len(ntfy.sends))
|
||||
}
|
||||
if len(telegram.sends) != 0 {
|
||||
t.Fatalf("sev3 must not hit telegram, got %d", len(telegram.sends))
|
||||
}
|
||||
if len(out) != 1 || out[0].Sendable.Channel != ChannelNtfy {
|
||||
t.Fatalf("want 1 ntfy dispatch, got %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchNudgeVoiceNoSessionSev4RoutesTelegramRepeatUntilAck(t *testing.T) {
|
||||
// present sev4 → [voice, ntfy]. voice has no session → away sev4 =
|
||||
// telegram-repeat-til-ack (NOT the present-list ntfy remainder).
|
||||
voice := &fakeSink{err: ErrVoiceNoSession}
|
||||
ntfy := &fakeSink{}
|
||||
telegram := &fakeSink{}
|
||||
ack := newFakeAck()
|
||||
rec := &fakeNudgeRecorder{}
|
||||
d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Telegram: telegram, Ack: ack, Nudges: rec})
|
||||
|
||||
out, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||
Candidate: candidate("service_down", loop.Sev4, store.Present),
|
||||
Body: "backup down", Summary: "backup down",
|
||||
}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
if len(telegram.sends) != 1 {
|
||||
t.Fatalf("sev4 voice-no-session: want 1 telegram send, got %d", len(telegram.sends))
|
||||
}
|
||||
if len(ntfy.sends) != 0 {
|
||||
t.Fatalf("sev4 away reroute must not fall to ntfy, got %d", len(ntfy.sends))
|
||||
}
|
||||
if len(out) != 1 || out[0].Sendable.Channel != ChannelTelegram || !out[0].Sendable.RepeatUntilAck {
|
||||
t.Fatalf("want 1 telegram RepeatUntilAck dispatch, got %+v", out)
|
||||
}
|
||||
if _, ok := ack.lastSent["service_down"]; !ok {
|
||||
t.Fatalf("repeat-til-ack reroute must MarkSent in the ack tracker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchNudgeVoiceNoSessionSev2Drops(t *testing.T) {
|
||||
// present sev2 → [voice]. voice has no session → away sev2 = drop.
|
||||
voice := &fakeSink{err: ErrVoiceNoSession}
|
||||
ntfy := &fakeSink{}
|
||||
telegram := &fakeSink{}
|
||||
rec := &fakeNudgeRecorder{}
|
||||
d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Telegram: telegram, Nudges: rec})
|
||||
|
||||
out, err := d.DispatchNudge(context.Background(), PhrasedNudge{
|
||||
Candidate: candidate("water", loop.Sev2, store.Present),
|
||||
Body: "drink water", Summary: "drink water",
|
||||
}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
if len(ntfy.sends) != 0 || len(telegram.sends) != 0 || len(out) != 0 {
|
||||
t.Fatalf("sev2 voice-no-session must drop silently, got ntfy=%d telegram=%d out=%d",
|
||||
len(ntfy.sends), len(telegram.sends), len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReminderVoiceNoSessionRoutesNtfy(t *testing.T) {
|
||||
// present reminder → [voice]. voice has no session → away = ntfy.
|
||||
voice := &fakeSink{err: ErrVoiceNoSession}
|
||||
ntfy := &fakeSink{}
|
||||
rc := &fakeReminderCompleter{}
|
||||
d := NewDispatcher(Config{Voice: voice, Ntfy: ntfy, Reminders: rc})
|
||||
|
||||
rd := loop.ReminderDecision{
|
||||
Reminder: store.Reminder{ID: 99, Status: "pending"},
|
||||
State: loop.State{Now: refNow(), Presence: store.Present},
|
||||
}
|
||||
out, err := d.DispatchReminder(context.Background(), PhrasedReminder{
|
||||
Decision: rd, Body: "wake up", Summary: "wake up",
|
||||
}, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
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(rc.marked) != 1 || rc.marked[0].status != "fired" {
|
||||
t.Fatalf("rerouted reminder must be marked fired: %+v", rc.marked)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------- repeat-til-ack -------------------------------
|
||||
|
||||
func TestShouldRepeat(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user