Compare commits

...

8 Commits

Author SHA1 Message Date
kami c860808528 Make make test actually gate on gofmt and vet
DESIGN.md has always said `make test` is "gofmt + vet + -race, no
exceptions". It only ever ran the tests, which is how nine files drifted
out of format without anyone noticing.

`test` now depends on `fmt-check` and `vet`. Checked that fmt-check does
fail when a file is unformatted, so the gate is real and not decorative.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 10:10:27 +04:00
kami f7442c3aea Run gofmt over the seven files that had drifted
Formatting only: import order, and statements that were packed onto one
line split out. `git diff -w` shows nothing but that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 10:09:32 +04:00
kami 75b067ac51 Merge the accepted-routine fix and drop reminder_id from accept
Two merge fixes on top of the branch:

- migrations: keep both new steps, snooze stays #8, the routine columns
  become #9. Both agents had numbered theirs #8.
- accepting no longer takes a reminder id, on the web surface too. The
  web accept path had the same one-shot-reminder bug the voice path did,
  so both now just flip the status and let the tick loop schedule.

The test that asserted "accept creates a reminder and links it" asserted
the bug. It now asserts that accepting creates no reminder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 10:07:37 +04:00
kami 424d1b3446 Fire accepted routines every interval, not once (Vikunja #366)
The tick loop now reads accepted routines from the store and nudges when
their interval has passed; accepting no longer builds a one-shot reminder.
Look at routine.DueAccepted for the schedule rule (no catch-up backlog) and
at fireAcceptedRoutines for the restraint gate — routines do not bypass it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 02:45:30 +04:00
kami c47886c2bc Merge branch 'worktree-agent-ab5b5c61a32cac4fe' into overnight-jul31 2026-07-31 02:42:53 +04:00
kami 54dc43516b Add accepted-routine timestamps to the store (Vikunja #366)
Data layer only. Migration #8 adds accepted_ts and last_fired_ts to
proposed_routines, plus ListAcceptedRoutines and MarkRoutineFired so the
tick loop can own the schedule. Accepting no longer links a reminder id.
Look at the TODO(vikunja#366) in cmd/mavend/tick.go for the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 02:41:58 +04:00
kami d30618ecb7 Stop the router repetition loop
Route now sets RepeatPenalty on the request, and the grammar's string rule is
capped at 120 characters. Two of 76 fixture cases looped one sentence inside
the text field until MaxTokens, which cut the JSON in half.
Reviewers: the new constant and the grammar string rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 02:11:38 +04:00
kami 17b47ce206 Route questions to query, not fact
The router prompt tested "reports current state -> fact" before "wants
information -> query", so a question naming a fact key was written as a fact.
Query now comes first, plus an explicit question test.
Reviewers: the prompt block in llmrouter.go, and the note about the
training-side copy of the prompt that needs the same edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 02:10:37 +04:00
25 changed files with 551 additions and 143 deletions
+15 -2
View File
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
.PHONY: all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test run-stt run-tts run-web download-embedder deps-go eval-router eval-recall .PHONY: 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 eval-router eval-recall
all: build all: build
@@ -69,7 +69,20 @@ deps-go:
done done
$(GO) version $(GO) version
test: # fmt-check fails if any file needs gofmt. DESIGN.md has always said `make
# test` gates on gofmt and vet; it did not, so nine files quietly drifted.
# Run `gofmt -w` on whatever this prints.
fmt-check:
@bad=$$(gofmt -l internal cmd); \
if [ -n "$$bad" ]; then \
echo "these files need gofmt:"; echo "$$bad"; exit 1; \
fi
vet:
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
$(GO) vet ./internal/... ./cmd/...
test: fmt-check vet
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
$(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/... $(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/...
+12 -12
View File
@@ -57,10 +57,10 @@ import (
"github.com/kami/maven/internal/delivery/ntfysink" "github.com/kami/maven/internal/delivery/ntfysink"
"github.com/kami/maven/internal/delivery/telegramsink" "github.com/kami/maven/internal/delivery/telegramsink"
"github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/store" "github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/webauthn" "github.com/kami/maven/internal/webauthn"
"github.com/kami/maven/internal/loop"
) )
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first") var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
@@ -161,12 +161,12 @@ func (l *lockedAPI) EnableTool(ctx context.Context, name string, cmd []string, d
return errLocked return errLocked
} }
func (l *lockedAPI) DisableTool(ctx context.Context, name string) error { return errLocked } func (l *lockedAPI) DisableTool(ctx context.Context, name string) error { return errLocked }
func (l *lockedAPI) DeleteTool(ctx context.Context, name string) error { return errLocked } func (l *lockedAPI) DeleteTool(ctx context.Context, name string) error { return errLocked }
func (l *lockedAPI) ListProposedRoutines(ctx context.Context) ([]ipc.ProposedRoutine, error) { func (l *lockedAPI) ListProposedRoutines(ctx context.Context) ([]ipc.ProposedRoutine, error) {
return nil, errLocked return nil, errLocked
} }
func (l *lockedAPI) DismissProposedRoutine(ctx context.Context, id int64) error { return errLocked } func (l *lockedAPI) DismissProposedRoutine(ctx context.Context, id int64) error { return errLocked }
func (l *lockedAPI) AcceptProposedRoutine(ctx context.Context, id, remID int64) error { func (l *lockedAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
return errLocked return errLocked
} }
func (l *lockedAPI) LookupTool(ctx context.Context, name string) (ipc.Tool, error) { func (l *lockedAPI) LookupTool(ctx context.Context, name string) (ipc.Tool, error) {
@@ -247,15 +247,15 @@ func run(args []string) error {
// ----- daemon components (only wired when unlocked) ----- // ----- daemon components (only wired when unlocked) -----
// Pre-declare so the unlock path can wire them later. // Pre-declare so the unlock path can wire them later.
var ( var (
gatherer *loop.Gatherer gatherer *loop.Gatherer
rules []loop.Rule rules []loop.Rule
phr phraser.Phraser phr phraser.Phraser
voiceW *voiceWiring voiceW *voiceWiring
dispatcher *delivery.Dispatcher dispatcher *delivery.Dispatcher
tl *tickLoop tl *tickLoop
coreAPI ipc.CoreAPI coreAPI ipc.CoreAPI
eco *ecosystemWiring eco *ecosystemWiring
factWorker *factEnrichmentWorker factWorker *factEnrichmentWorker
) )
if !locked { if !locked {
+4 -1
View File
@@ -9,7 +9,10 @@ import (
"github.com/kami/maven/internal/voice" "github.com/kami/maven/internal/voice"
) )
type mockCompleter struct{ out string; err error } type mockCompleter struct {
out string
err error
}
func (m mockCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err } func (m mockCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err }
+62
View File
@@ -186,6 +186,10 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
// LLM-phrased — so a routine can't hallucinate. severity comes from config. // LLM-phrased — so a routine can't hallucinate. severity comes from config.
t.fireRoutines(ctx, now, state) t.fireRoutines(ctx, now, state)
// accepted routines: patterns the user confirmed. read straight from the
// store each tick so the schedule survives a restart.
t.fireAcceptedRoutines(ctx, now, state)
// morning routines: daily checklists (medicine/water/pets/...), nagged at // morning routines: daily checklists (medicine/water/pets/...), nagged at
// most once per day per routine, and only for items still unevidenced at // most once per day per routine, and only for items still unevidenced at
// nudge time. See internal/morning for the "why not four timers" rationale. // nudge time. See internal/morning for the "why not four timers" rationale.
@@ -377,6 +381,64 @@ func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.S
} }
} }
// fireAcceptedRoutines nudges about the routines the user accepted, once per
// interval (Vikunja #366). Accepting used to create a single reminder, so a
// non-weekly routine fired once and went quiet forever; the schedule lives in
// the proposed_routines row now and the loop re-reads it every tick.
//
// A routine is a care-class nudge and goes through the restraint gate like any
// other: quiet hours, away presence and snooze all suppress it. Reminders bypass
// that gate; routines must not. A suppressed nudge is NOT marked fired, so it
// goes out on the next tick that the gate allows — one nudge, held, not dropped
// and not repeated.
//
// The body is literal text built from the detected action and object, not
// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts.
func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) {
rows, err := t.store.ListAcceptedRoutines(ctx)
if err != nil {
log.Printf("tick: list accepted routines: %v", err)
return
}
accepted := make([]routine.Accepted, 0, len(rows))
for _, r := range rows {
if r.AcceptedTs == nil {
continue // accepted before the schedule column existed — no clock to start from.
}
accepted = append(accepted, routine.Accepted{
ID: r.ID,
Name: r.Action + " " + r.Object,
IntervalDays: r.IntervalDays,
Accepted: *r.AcceptedTs,
LastFired: r.LastFiredTs,
})
}
for _, a := range routine.DueAccepted(accepted, now) {
rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1}
if !loop.Gate(state, rule) {
continue
}
body := "пора: " + a.Name
pn := delivery.PhrasedNudge{
Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state},
Body: body,
Summary: body,
}
sent, err := t.dispatcher.DispatchNudge(ctx, pn, now)
if err != nil {
log.Printf("tick: dispatch accepted routine %d: %v", a.ID, err)
continue
}
if len(sent) == 0 {
continue // routing dropped it — leave it due.
}
if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil {
log.Printf("tick: mark routine %d fired: %v", a.ID, err)
}
}
}
// fireMorningRoutines checks each configured checklist against today's facts // fireMorningRoutines checks each configured checklist against today's facts
// and dispatches a nag listing exactly what's still missing, at most once per // and dispatches a nag listing exactly what's still missing, at most once per
// routine per calendar day. Fact reads happen here (not in loop.Gatherer) // routine per calendar day. Fact reads happen here (not in loop.Gatherer)
+109
View File
@@ -96,6 +96,115 @@ func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
} }
} }
// TestTickFiresAcceptedRoutineEveryInterval — Vikunja #366. An accepted routine
// with a 3-day interval must nudge every 3 days, not once. It also must not
// replay the occurrences it slept through: after a 30-day gap it nudges once.
func TestTickFiresAcceptedRoutineEveryInterval(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
accepted := refNow()
id, err := st.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, accepted)
if err != nil {
t.Fatalf("CreateProposedRoutine: %v", err)
}
if err := st.AcceptProposedRoutine(ctx, id, accepted); err != nil {
t.Fatalf("AcceptProposedRoutine: %v", err)
}
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
const rule = "routine:полить цветы"
// Same day as the accept: not due yet.
markPresent(t, st, ctx, accepted)
tl.tick(ctx, accepted.Add(time.Hour))
if n := countSends(sink, rule); n != 0 {
t.Fatalf("routine fired %d times before its first interval passed, want 0", n)
}
// Three days later: the first nudge.
first := accepted.Add(3 * 24 * time.Hour)
markPresent(t, st, ctx, first)
tl.tick(ctx, first)
if n := countSends(sink, rule); n != 1 {
t.Fatalf("first interval: sends = %d, want 1", n)
}
// Next day: still inside the interval, silent.
sink.sends = nil
markPresent(t, st, ctx, first.Add(24*time.Hour))
tl.tick(ctx, first.Add(24*time.Hour))
if n := countSends(sink, rule); n != 0 {
t.Fatalf("mid-interval: sends = %d, want 0", n)
}
// Three days after the first nudge: it fires again. This is the bug —
// a one-shot reminder would never come back.
second := first.Add(3 * 24 * time.Hour)
markPresent(t, st, ctx, second)
tl.tick(ctx, second)
if n := countSends(sink, rule); n != 1 {
t.Fatalf("second interval: sends = %d, want 1 (a routine repeats)", n)
}
// A long silence must not turn into a backlog of missed nudges.
sink.sends = nil
late := second.Add(30 * 24 * time.Hour)
markPresent(t, st, ctx, late)
tl.tick(ctx, late)
if n := countSends(sink, rule); n != 1 {
t.Fatalf("after a 30-day gap: sends = %d, want exactly 1 (no backlog)", n)
}
}
// TestTickAcceptedRoutineRespectsQuietHours — routines are not reminders: they
// do not inherit the reminder gate bypass. Away presence drops a care-class
// nudge, and the routine stays due so it nudges once the user is back.
func TestTickAcceptedRoutineRespectsGate(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
accepted := refNow()
id, err := st.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, accepted)
if err != nil {
t.Fatalf("CreateProposedRoutine: %v", err)
}
if err := st.AcceptProposedRoutine(ctx, id, accepted); err != nil {
t.Fatalf("AcceptProposedRoutine: %v", err)
}
sink := &fakeSink{}
tl := newTestTickLoop(t, st, sink, nil)
const rule = "routine:полить цветы"
// No presence probes at all ⇒ away ⇒ the care gate blocks the nudge.
due := accepted.Add(3 * 24 * time.Hour)
tl.tick(ctx, due)
if n := countSends(sink, rule); n != 0 {
t.Fatalf("away: sends = %d, want 0 (routine must not bypass the gate)", n)
}
// Back at the desk a minute later: the nudge that was held now goes out.
back := due.Add(time.Minute)
markPresent(t, st, ctx, back)
tl.tick(ctx, back)
if n := countSends(sink, rule); n != 1 {
t.Fatalf("present again: sends = %d, want 1", n)
}
}
// countSends counts captured sends for one rule name.
func countSends(sink *fakeSink, rule string) int {
n := 0
for _, s := range sink.sends {
if s.RuleName == rule {
n++
}
}
return n
}
// refNow — fixed tick time so presence decay + since durations are deterministic. // refNow — fixed tick time so presence decay + since durations are deterministic.
func refNow() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) } func refNow() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) }
+5 -16
View File
@@ -1421,23 +1421,12 @@ func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (stri
switch classifyConfirm(text) { switch classifyConfirm(text) {
case confirmYes: case confirmYes:
h.pendingRoutine = nil h.pendingRoutine = nil
// Create a recurring reminder at the detected interval. // Only record the acceptance. The tick loop reads accepted
// Weekly patterns get a cron expression; arbitrary intervals // routines and nudges on their own interval. Building a reminder
// fire once and the detector re-proposes on the next cycle. // here made a routine fire exactly once (Vikunja #366).
intervalDur := time.Duration(pr.interval * 24 * float64(time.Hour)) if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, h.now()); err != nil {
fire := h.now().Add(intervalDur)
cron := ""
if pr.interval >= 6.5 && pr.interval <= 7.5 {
cron = fmt.Sprintf("0 %d * * %d", fire.Hour(), int(fire.Weekday()))
}
payload := fmt.Sprintf(`{"text":"%s %s"}`, pr.action, pr.object)
remID, err := h.api.CreateReminder(ctx, fire, payload, cron)
if err != nil {
log.Printf("voice: create routine reminder: %v", err)
return "не получилось поставить напоминание.", true
}
if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, remID); err != nil {
log.Printf("voice: accept proposed routine: %v", err) log.Printf("voice: accept proposed routine: %v", err)
return "не получилось запомнить рутину.", true
} }
return "буду напоминать.", true return "буду напоминать.", true
case confirmNo: case confirmNo:
+4 -1
View File
@@ -91,7 +91,10 @@ func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs) {
var d ecoData var d ecoData
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(3) wg.Add(3)
go func() { defer wg.Done(); d.Nexus.Err = getEco(ctx, urls.nexus, "/api/v1/entities?limit=50", &d.Nexus.Rows) }() go func() {
defer wg.Done()
d.Nexus.Err = getEco(ctx, urls.nexus, "/api/v1/entities?limit=50", &d.Nexus.Rows)
}()
go func() { go func() {
defer wg.Done() defer wg.Done()
d.Praxis.Err = getEco(ctx, urls.praxis, "/api/v1/items?limit=50", &d.Praxis.Rows) d.Praxis.Err = getEco(ctx, urls.praxis, "/api/v1/items?limit=50", &d.Praxis.Rows)
+11 -8
View File
@@ -928,7 +928,6 @@ type routineCore struct {
routines []ipc.ProposedRoutine routines []ipc.ProposedRoutine
dismissed int64 dismissed int64
acceptedID int64 acceptedID int64
acceptedRe int64
remCron string remCron string
} }
@@ -941,8 +940,8 @@ func (c *routineCore) DismissProposedRoutine(_ context.Context, id int64) error
return nil return nil
} }
func (c *routineCore) AcceptProposedRoutine(_ context.Context, id, remID int64) error { func (c *routineCore) AcceptProposedRoutine(_ context.Context, id int64) error {
c.acceptedID, c.acceptedRe = id, remID c.acceptedID = id
return nil return nil
} }
@@ -990,18 +989,22 @@ func TestHandleRoutines_Accept_RequiresStepUp(t *testing.T) {
} }
} }
func TestHandleRoutines_Accept_CreatesReminderAndLinksIt(t *testing.T) { // Accepting only flips the status. It used to also create a one-shot reminder,
// which is why a non-weekly routine fired once and then went quiet forever
// (Vikunja #366). The tick loop owns the schedule now, so a reminder here would
// be a second, competing schedule.
func TestHandleRoutines_Accept_FlipsStatusAndMakesNoReminder(t *testing.T) {
core := weeklyRoutineCore() core := weeklyRoutineCore()
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handleRoutines(rr, postRoutine("accept", "3"), core, stepUpSession(), false) handleRoutines(rr, postRoutine("accept", "3"), core, stepUpSession(), false)
if rr.Code != http.StatusOK { if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
} }
if core.acceptedID != 3 || core.acceptedRe != 77 { if core.acceptedID != 3 {
t.Fatalf("accepted id=%d reminder=%d, want 3 and 77", core.acceptedID, core.acceptedRe) t.Fatalf("accepted id = %d, want 3", core.acceptedID)
} }
if core.remCron == "" { if core.remCron != "" {
t.Fatal("a weekly pattern should get a cron expression") t.Fatalf("accepting must not create a reminder, got cron %q", core.remCron)
} }
} }
+5 -14
View File
@@ -898,20 +898,11 @@ func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
return errors.New("no such proposed routine") return errors.New("no such proposed routine")
} }
fire := time.Now().Add(time.Duration(found.IntervalDays * 24 * float64(time.Hour))) // No reminder is created here. Accepting only flips the status; the tick
cron := "" // loop reads accepted routines and nudges on the interval (Vikunja #366).
if found.IntervalDays >= 6.5 && found.IntervalDays <= 7.5 { // The old code made a one-shot reminder, so a non-weekly routine fired
cron = fmt.Sprintf("0 %d * * %d", fire.Hour(), int(fire.Weekday())) // once and then went quiet forever.
} return core.AcceptProposedRoutine(ctx, id)
payload, err := json.Marshal(map[string]string{"text": found.Action + " " + found.Object})
if err != nil {
return err
}
remID, err := core.CreateReminder(ctx, fire, string(payload), cron)
if err != nil {
return err
}
return core.AcceptProposedRoutine(ctx, id, remID)
} }
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
+1 -1
View File
@@ -446,7 +446,7 @@ func (r *recordingAPI) RevertFact(_ context.Context, _ string) (int64, error) {
func (r *recordingAPI) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) { func (r *recordingAPI) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) {
return nil, nil return nil, nil
} }
func (r *recordingAPI) AcceptProposedRoutine(_ context.Context, _, _ int64) error { func (r *recordingAPI) AcceptProposedRoutine(_ context.Context, _ int64) error {
return nil return nil
} }
func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error { func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error {
+4 -5
View File
@@ -238,8 +238,7 @@ type dismissProposedRoutineReq struct {
} }
type acceptProposedRoutineReq struct { type acceptProposedRoutineReq struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ReminderID int64 `json:"reminder_id"`
} }
// CoreAPI — what core exposes to modules. One Go interface, satisfied by: // CoreAPI — what core exposes to modules. One Go interface, satisfied by:
@@ -291,9 +290,9 @@ type CoreAPI interface {
ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error)
// DismissProposedRoutine flips a proposed routine to 'dismissed'. // DismissProposedRoutine flips a proposed routine to 'dismissed'.
DismissProposedRoutine(ctx context.Context, id int64) error DismissProposedRoutine(ctx context.Context, id int64) error
// AcceptProposedRoutine flips a proposed routine to 'accepted' and links // AcceptProposedRoutine flips a proposed routine to 'accepted'. The tick
// the reminder that will fire it. The caller creates the reminder first. // loop takes the schedule from there — no reminder is created (Vikunja #366).
AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error AcceptProposedRoutine(ctx context.Context, id int64) error
// TickTrace returns the most recent tick's rule trace. The daemon caches // TickTrace returns the most recent tick's rule trace. The daemon caches
// this after every tick; the store adapter returns an error (trace is not // this after every tick; the store adapter returns an error (trace is not
+2 -2
View File
@@ -430,8 +430,8 @@ func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error {
return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil) return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil)
} }
func (c *Client) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error { func (c *Client) AcceptProposedRoutine(ctx context.Context, id int64) error {
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id, ReminderID: reminderID}, nil) return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil)
} }
func (c *Client) Chat(ctx context.Context, text string) (string, error) { func (c *Client) Chat(ctx context.Context, text string) (string, error) {
+1 -1
View File
@@ -478,7 +478,7 @@ func (a *chatTestAPI) DeleteTool(ctx context.Context, name string) error {
func (a *chatTestAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) { func (a *chatTestAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
return nil, ErrUnknownMethod return nil, ErrUnknownMethod
} }
func (a *chatTestAPI) AcceptProposedRoutine(ctx context.Context, id, remID int64) error { func (a *chatTestAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
return nil return nil
} }
func (a *chatTestAPI) DismissProposedRoutine(ctx context.Context, id int64) error { func (a *chatTestAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
+3 -3
View File
@@ -253,8 +253,8 @@ func (a *storeAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
return mapErr(a.s.DismissProposedRoutine(ctx, id)) return mapErr(a.s.DismissProposedRoutine(ctx, id))
} }
func (a *storeAPI) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error { func (a *storeAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
return mapErr(a.s.AcceptProposedRoutine(ctx, id, reminderID)) return mapErr(a.s.AcceptProposedRoutine(ctx, id, time.Now().UTC()))
} }
func toTool(t store.Tool) Tool { func toTool(t store.Tool) Tool {
@@ -783,7 +783,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err return nil, err
} }
return marshalResult(nil), api.AcceptProposedRoutine(ctx, p.ID, p.ReminderID) return marshalResult(nil), api.AcceptProposedRoutine(ctx, p.ID)
case MethodRevertFact: case MethodRevertFact:
var p struct { var p struct {
+27 -27
View File
@@ -13,39 +13,39 @@ import (
type Method string type Method string
const ( const (
MethodWriteFact Method = "write_fact" MethodWriteFact Method = "write_fact"
MethodLatestFact Method = "latest_fact" MethodLatestFact Method = "latest_fact"
MethodLatestFactBySource Method = "latest_fact_by_source" MethodLatestFactBySource Method = "latest_fact_by_source"
MethodSince Method = "since" MethodSince Method = "since"
MethodPresence Method = "presence" MethodPresence Method = "presence"
MethodCreateReminder Method = "create_reminder" MethodCreateReminder Method = "create_reminder"
MethodMarkReminder Method = "mark_reminder" MethodMarkReminder Method = "mark_reminder"
MethodListReminders Method = "list_reminders" MethodListReminders Method = "list_reminders"
MethodRecordNudge Method = "record_nudge" MethodRecordNudge Method = "record_nudge"
MethodResolveNudge Method = "resolve_nudge" MethodResolveNudge Method = "resolve_nudge"
MethodRecentOutcomes Method = "recent_outcomes" MethodRecentOutcomes Method = "recent_outcomes"
MethodRecentFacts Method = "recent_facts" MethodRecentFacts Method = "recent_facts"
MethodCalendarEvents Method = "calendar_events" MethodCalendarEvents Method = "calendar_events"
MethodRecentNudges Method = "recent_nudges" MethodRecentNudges Method = "recent_nudges"
MethodWriteNote Method = "write_note" MethodWriteNote Method = "write_note"
MethodQueryNotes Method = "query_notes" MethodQueryNotes Method = "query_notes"
MethodRecentNotes Method = "recent_notes" MethodRecentNotes Method = "recent_notes"
MethodProposeTool Method = "propose_tool" MethodProposeTool Method = "propose_tool"
MethodEnableTool Method = "enable_tool" MethodEnableTool Method = "enable_tool"
MethodDisableTool Method = "disable_tool" MethodDisableTool Method = "disable_tool"
MethodAssertStepUp Method = "assert_stepup" MethodAssertStepUp Method = "assert_stepup"
MethodStoreEncryptionKey Method = "store_encryption_key" MethodStoreEncryptionKey Method = "store_encryption_key"
MethodUnlock Method = "unlock" MethodUnlock Method = "unlock"
MethodLookupTool Method = "lookup_tool" MethodLookupTool Method = "lookup_tool"
MethodListTools Method = "list_tools" MethodListTools Method = "list_tools"
MethodDeleteTool Method = "delete_tool" MethodDeleteTool Method = "delete_tool"
MethodListProposedRoutines Method = "list_proposed_routines" MethodListProposedRoutines Method = "list_proposed_routines"
MethodDismissProposedRoutine Method = "dismiss_proposed_routine" MethodDismissProposedRoutine Method = "dismiss_proposed_routine"
MethodAcceptProposedRoutine Method = "accept_proposed_routine" MethodAcceptProposedRoutine Method = "accept_proposed_routine"
MethodRevertFact Method = "revert_fact" MethodRevertFact Method = "revert_fact"
MethodTickTrace Method = "tick_trace" MethodTickTrace Method = "tick_trace"
MethodMorningStatus Method = "morning_status" MethodMorningStatus Method = "morning_status"
MethodChat Method = "chat" MethodChat Method = "chat"
) )
// Request — one frame from module to core. Params is the JSON-encoded argument // Request — one frame from module to core. Params is the JSON-encoded argument
+1 -1
View File
@@ -19,7 +19,7 @@ func TestComplete(t *testing.T) {
t.Errorf("path = %q, want /v1/chat/completions", r.URL.Path) t.Errorf("path = %q, want /v1/chat/completions", r.URL.Path)
} }
var reqBody struct { var reqBody struct {
Messages []struct { Messages []struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
} `json:"messages"` } `json:"messages"`
+4 -4
View File
@@ -26,10 +26,10 @@ func TestPythonDateParser(t *testing.T) {
ctx := context.Background() ctx := context.Background()
tests := []struct { tests := []struct {
name string name string
text string text string
wantOK bool wantOK bool
checkT func(t *testing.T, got, now time.Time) checkT func(t *testing.T, got, now time.Time)
}{ }{
{ {
name: "ru relative — через час", name: "ru relative — через час",
+33 -8
View File
@@ -23,17 +23,27 @@ func NewLLMRouter(c Completer) *LLMRouter { return &LLMRouter{c: c} }
// routeGrammar — GBNF constraining the model to a JSON ARRAY of fixed-shape // routeGrammar — GBNF constraining the model to a JSON ARRAY of fixed-shape
// action objects (one per ask; compound utterances → multiple). Enum + key set // action objects (one per ask; compound utterances → multiple). Enum + key set
// prevent free-form drift from a sub-1B model. // prevent free-form drift from a sub-1B model. The string rule is length-bounded
// so a repetition loop cannot fill the whole token budget with one field and
// truncate the JSON.
const routeGrammar = ` const routeGrammar = `
root ::= "[" ws action ("," ws action)* ws "]" root ::= "[" ws action ("," ws action)* ws "]"
action ::= "{" ws "\"intent\"" ws ":" ws intent ("," ws field)* ws "}" action ::= "{" ws "\"intent\"" ws ":" ws intent ("," ws field)* ws "}"
intent ::= "\"fact\"" | "\"reminder\"" | "\"note\"" | "\"query\"" | "\"act\"" | "\"chat\"" | "\"system\"" intent ::= "\"fact\"" | "\"reminder\"" | "\"note\"" | "\"query\"" | "\"act\"" | "\"chat\"" | "\"system\""
field ::= key ws ":" ws string field ::= key ws ":" ws string
key ::= "\"key\"" | "\"value\"" | "\"text\"" | "\"verb\"" key ::= "\"key\"" | "\"value\"" | "\"text\"" | "\"verb\""
string ::= "\"" ([^"\\] | "\\" .)* "\"" string ::= "\"" ([^"\\] | "\\" .){0,120} "\""
ws ::= [ \t\n]* ws ::= [ \t\n]*
` `
// routeSystem — the router prompt. Changed 31-07-2026: the query test now sits
// above the fact test and there is an explicit question test. Before that, a
// question naming a fact key ("сколько воды я выпил с утра") matched the fact
// rule first and was stored as an assertion — 15 of 76 fixture cases.
//
// The training workspace keeps its own copy of this prompt for relabelling, and
// `llm/check_prompt_parity.py` there compares the two. That copy is in another
// repo and was not touched, so parity will fail until it gets the same edit.
const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий. const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий.
Ровно одно намерение: fact, reminder, note, query, act, chat, system. Ровно одно намерение: fact, reminder, note, query, act, chat, system.
@@ -41,22 +51,26 @@ const routeSystem = `Классифицируй ровно одно сообще
Классифицируй по цели пользователя. Порядок решения: Классифицируй по цели пользователя. Порядок решения:
1. Хочет напоминание в будущем reminder 1. Хочет напоминание в будущем reminder
2. Явно просит сохранить информацию note 2. Явно просит сохранить информацию note
3. Сообщает или обновляет текущее состояние/событие fact 3. Задаёт вопрос: есть вопросительное слово (сколько, что, какой, когда, где, кто, почему, как) или знак «?» query
4. Хочет получить информацию query 4. Хочет получить информацию, в том числе о своих же данных query
5. Просит выполнить работу act 5. Утверждает: сообщает или обновляет текущее состояние/событие fact
6. Про ассистента, настройки или память system 6. Просит выполнить работу act
7. Иначе chat 7. Про ассистента, настройки или память system
8. Иначе chat
Различия: Различия:
- note сохранить информацию, без напоминания. text = суть. - note сохранить информацию, без напоминания. text = суть.
- reminder уведомить позже. text = что напомнить. - reminder уведомить позже. text = что напомнить.
- fact неявное обновление: пользователь сообщает, что что-то в мире изменилось (текущее/изменённое состояние, случившееся событие). key/value. - fact неявное обновление: пользователь сообщает, что что-то в мире изменилось (текущее/изменённое состояние, случившееся событие). key/value.
- query против fact решает форма реплики, а не тема. Вопрос о состоянии это query, даже если названо то же самое, что бывает в fact. Только утверждение это fact.
Примеры: Примеры:
"запиши пароль" {"intent":"note","text":"пароль"} "запиши пароль" {"intent":"note","text":"пароль"}
"напомни купить молоко" {"intent":"reminder","text":"купить молоко"} "напомни купить молоко" {"intent":"reminder","text":"купить молоко"}
"запиши купить молоко" {"intent":"note","text":"купить молоко"} "запиши купить молоко" {"intent":"note","text":"купить молоко"}
"я выпил воду" {"intent":"fact","key":"water","value":"выпил"} "я выпил воду" {"intent":"fact","key":"water","value":"выпил"}
"сколько воды я выпил с утра" {"intent":"query","text":"сколько воды я выпил с утра"}
"сколько раз я ел вчера?" {"intent":"query","text":"сколько раз я ел вчера"}
"мой любимый фильм — Интерстеллар" {"intent":"note","text":"любимый фильм — Интерстеллар"} "мой любимый фильм — Интерстеллар" {"intent":"note","text":"любимый фильм — Интерстеллар"}
"что такое docker?" {"intent":"query","text":"что такое docker"} "что такое docker?" {"intent":"query","text":"что такое docker"}
"напиши письмо" {"intent":"act","verb":"написать письмо"} "напиши письмо" {"intent":"act","verb":"написать письмо"}
@@ -65,6 +79,11 @@ const routeSystem = `Классифицируй ровно одно сообще
Ответ JSON-массив: по одному объекту на каждую просьбу. Обычно один. Если в реплике несколько просьб по объекту на каждую. "напомни купить молоко, и запиши что кофе кончился" [{"intent":"reminder","text":"купить молоко"},{"intent":"note","text":"кофе кончился"}]. Только JSON, без пояснений.` Ответ JSON-массив: по одному объекту на каждую просьбу. Обычно один. Если в реплике несколько просьб по объекту на каждую. "напомни купить молоко, и запиши что кофе кончился" [{"intent":"reminder","text":"купить молоко"},{"intent":"note","text":"кофе кончился"}]. Только JSON, без пояснений.`
// routeRepeatPenalty — the sub-1B model loops one sentence inside the text field
// until it runs out of tokens, which truncates the JSON. 1.15 is enough to break
// the loop without hurting short slot values.
const routeRepeatPenalty = 1.15
type routeAction struct { type routeAction struct {
Intent string `json:"intent"` Intent string `json:"intent"`
Key string `json:"key"` Key string `json:"key"`
@@ -74,7 +93,13 @@ type routeAction struct {
} }
func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time) (Decision, bool, error) { func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time) (Decision, bool, error) {
raw, err := lr.c.Complete(ctx, llm.Req{System: routeSystem, User: utterance, Grammar: routeGrammar, MaxTokens: 128}) raw, err := lr.c.Complete(ctx, llm.Req{
System: routeSystem,
User: utterance,
Grammar: routeGrammar,
MaxTokens: 128,
RepeatPenalty: routeRepeatPenalty,
})
if err != nil { if err != nil {
return Decision{}, false, err return Decision{}, false, err
} }
+48 -2
View File
@@ -3,15 +3,61 @@ package router
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"testing" "testing"
"time" "time"
"github.com/kami/maven/internal/llm" "github.com/kami/maven/internal/llm"
) )
type mockLLM struct{ out string; err error } type mockLLM struct {
out string
err error
got *llm.Req // last request, when the test wants to inspect it
}
func (m mockLLM) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err } func (m mockLLM) Complete(_ context.Context, r llm.Req) (string, error) {
if m.got != nil {
*m.got = r
}
return m.out, m.err
}
// Without a repeat penalty the model loops inside the text field until MaxTokens
// and the truncated JSON fails to parse.
func TestLLMRouterSetsRepeatPenalty(t *testing.T) {
var got llm.Req
lr := NewLLMRouter(mockLLM{out: `{"intent":"chat","text":"привет"}`, got: &got})
if _, _, err := lr.Route(context.Background(), "привет", time.Now()); err != nil {
t.Fatalf("route: %v", err)
}
if got.RepeatPenalty <= 1 {
t.Fatalf("want repeat penalty above 1, got %v", got.RepeatPenalty)
}
}
// An unbounded string rule lets one field eat the whole token budget.
func TestRouteGrammarBoundsStrings(t *testing.T) {
if !strings.Contains(routeGrammar, `string ::= "\"" ([^"\\] | "\\" .){0,120} "\""`) {
t.Fatal("grammar string rule lost its length bound")
}
}
// A question naming a fact key used to be stored as a fact because the fact rule
// was tested first. Keep the query rule above it.
func TestRoutePromptTestsQueryBeforeFact(t *testing.T) {
query := strings.Index(routeSystem, "→ query")
fact := strings.Index(routeSystem, "состояние/событие → fact")
if query < 0 || fact < 0 {
t.Fatalf("prompt lost a rule: query=%d fact=%d", query, fact)
}
if query > fact {
t.Fatal("query rule must come before the fact rule")
}
if !strings.Contains(routeSystem, "Задаёт вопрос") {
t.Fatal("prompt lost the explicit question test")
}
}
func TestLLMRouterFactMapping(t *testing.T) { func TestLLMRouterFactMapping(t *testing.T) {
lr := NewLLMRouter(mockLLM{out: `{"intent":"fact","key":"water","value":"выпил"}`}) lr := NewLLMRouter(mockLLM{out: `{"intent":"fact","key":"water","value":"выпил"}`})
+41
View File
@@ -55,6 +55,47 @@ func Validate(routines []Routine) error {
return nil return nil
} }
// Accepted — an accepted routine proposal as the tick driver sees it. This is a
// different shape from Routine: the schedule is a plain interval the pattern
// detector measured, not an operator-written cron expression. Accepted is when
// the human said yes; LastFired is nil until the first nudge.
type Accepted struct {
ID int64
Name string
IntervalDays float64
Accepted time.Time
LastFired *time.Time
}
// DueAccepted returns the accepted routines whose interval has passed. It does
// not mutate anything — the caller persists the new last-fired time, because
// that has to survive a restart (unlike Due's in-memory map).
//
// The clock starts at LastFired, or at Accepted for a routine that has never
// nudged. A routine with a non-positive interval never fires: a bad interval
// should mean silence, not a nudge every tick.
//
// One occurrence per call, no catch-up: the caller stamps the fire time as now,
// so a routine that was silent for a month nudges once and then waits a full
// interval. Never a backlog.
func DueAccepted(rs []Accepted, now time.Time) []Accepted {
var out []Accepted
for _, r := range rs {
if r.IntervalDays <= 0 {
continue
}
since := r.Accepted
if r.LastFired != nil {
since = *r.LastFired
}
gap := time.Duration(r.IntervalDays * 24 * float64(time.Hour))
if !now.Before(since.Add(gap)) {
out = append(out, r)
}
}
return out
}
// Due returns the routines whose schedule crossed since their last fire and // Due returns the routines whose schedule crossed since their last fire and
// records now as the new last-fire time for each one returned. The caller owns // records now as the new last-fire time for each one returned. The caller owns
// `last` (the tick driver holds it across ticks); Due mutates it in place. // `last` (the tick driver holds it across ticks); Due mutates it in place.
+34
View File
@@ -5,6 +5,40 @@ import (
"time" "time"
) )
func TestDueAcceptedFiresOncePerInterval(t *testing.T) {
accepted := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC)
fired := accepted.Add(3 * 24 * time.Hour)
rs := []Accepted{
{ID: 1, Name: "полить цветы", IntervalDays: 3, Accepted: accepted},
{ID: 2, Name: "покормить рыб", IntervalDays: 3, Accepted: accepted, LastFired: &fired},
{ID: 3, Name: "битый интервал", IntervalDays: 0, Accepted: accepted},
}
// One day in: nothing has waited a full interval.
if got := DueAccepted(rs, accepted.Add(24*time.Hour)); len(got) != 0 {
t.Fatalf("want nothing due after 1 day, got %+v", got)
}
// Three days in: the never-fired one is due. The one that already fired at
// day 3 starts its next three days from there. A zero interval never fires.
got := DueAccepted(rs, fired)
if len(got) != 1 || got[0].ID != 1 {
t.Fatalf("want only routine 1 due at day 3, got %+v", got)
}
// Six days in: both real routines are due.
if got := DueAccepted(rs, accepted.Add(6*24*time.Hour)); len(got) != 2 {
t.Fatalf("want both routines due at day 6, got %+v", got)
}
// A month later the zero-interval routine is still silent.
for _, r := range DueAccepted(rs, accepted.Add(30*24*time.Hour)) {
if r.ID == 3 {
t.Fatal("a routine with a zero interval must never fire")
}
}
}
func TestValidate(t *testing.T) { func TestValidate(t *testing.T) {
ok := []Routine{{Name: "morning", Cron: "0 8 * * *", Body: "доброе утро"}} ok := []Routine{{Name: "morning", Cron: "0 8 * * *", Body: "доброе утро"}}
if err := Validate(ok); err != nil { if err := Validate(ok); err != nil {
+2
View File
@@ -72,6 +72,8 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
CREATE INDEX IF NOT EXISTS idx_facts_resolution_pending ON facts (resolution_state) WHERE resolution_state = 'pending';`, // #7 — entity-aware memory (Vikunja #279): facts about a subject get resolved to a Nexus entity_id async CREATE INDEX IF NOT EXISTS idx_facts_resolution_pending ON facts (resolution_state) WHERE resolution_state = 'pending';`, // #7 — entity-aware memory (Vikunja #279): facts about a subject get resolved to a Nexus entity_id async
`CREATE INDEX IF NOT EXISTS idx_nudges_snoozed ON nudges (outcome_ts) WHERE outcome = 'snoozed';`, // #8 — SnoozedUntil runs every tick; keep it off a full scan (Vikunja #364) `CREATE INDEX IF NOT EXISTS idx_nudges_snoozed ON nudges (outcome_ts) WHERE outcome = 'snoozed';`, // #8 — SnoozedUntil runs every tick; keep it off a full scan (Vikunja #364)
`ALTER TABLE proposed_routines ADD COLUMN accepted_ts INTEGER;
ALTER TABLE proposed_routines ADD COLUMN last_fired_ts INTEGER;`, // #9 — accepted routines keep firing (Vikunja #366): the tick loop needs to know when a routine was accepted and when it last nudged
} }
// migrate applies every migration with a number greater than the DB's current // migrate applies every migration with a number greater than the DB's current
+70 -20
View File
@@ -16,10 +16,15 @@ const (
RoutineDismissed = "dismissed" RoutineDismissed = "dismissed"
) )
// ProposedRoutine — a detected pattern the system wants to turn into a // ProposedRoutine — a detected pattern the system wants to nudge about on a
// recurring reminder. Status 'proposed' means awaiting human confirmation; // repeating interval. Status 'proposed' means awaiting human confirmation;
// 'accepted' means the human confirmed and a reminder was created (reminder_id // 'accepted' means the human confirmed and the tick loop now owns the schedule;
// set); 'dismissed' means the human declined and we won't re-propose. // 'dismissed' means the human declined and we won't re-propose.
//
// AcceptedTs is when the human said yes; it is the clock start for the first
// nudge. LastFiredTs is when the last nudge went out, nil until the first one.
// ReminderID is only set on rows accepted before Vikunja #366, when accepting
// created a one-shot reminder instead.
type ProposedRoutine struct { type ProposedRoutine struct {
ID int64 ID int64
Action string Action string
@@ -27,7 +32,9 @@ type ProposedRoutine struct {
IntervalDays float64 IntervalDays float64
Status string // proposed | accepted | dismissed Status string // proposed | accepted | dismissed
CreatedTs time.Time CreatedTs time.Time
ReminderID *int64 // set when accepted ReminderID *int64
AcceptedTs *time.Time
LastFiredTs *time.Time
} }
var ( var (
@@ -74,7 +81,7 @@ func (s *Store) CreateProposedRoutine(ctx context.Context, action, object string
// nil (no error) when no row exists. // nil (no error) when no row exists.
func (s *Store) LookupProposedRoutine(ctx context.Context, action, object string) (*ProposedRoutine, error) { func (s *Store) LookupProposedRoutine(ctx context.Context, action, object string) (*ProposedRoutine, error) {
row := s.db.QueryRowContext(ctx, ` row := s.db.QueryRowContext(ctx, `
SELECT id, action, object, interval_days, status, created_ts, reminder_id SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
FROM proposed_routines FROM proposed_routines
WHERE action = ? AND object = ?`, action, object) WHERE action = ? AND object = ?`, action, object)
r, err := scanProposedRoutine(row) r, err := scanProposedRoutine(row)
@@ -95,12 +102,8 @@ func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, er
// ListProposedRoutinesByStatus returns routines in one status, newest first. // ListProposedRoutinesByStatus returns routines in one status, newest first.
// An empty status returns every row. // An empty status returns every row.
//
// TODO(vikunja#46): the tick loop should read the accepted ones from here so a
// routine the human said yes to has a home the loop can see, instead of only
// the reminder row that accepting happened to create.
func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string) ([]ProposedRoutine, error) { func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string) ([]ProposedRoutine, error) {
q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
FROM proposed_routines` FROM proposed_routines`
var args []any var args []any
if status != "" { if status != "" {
@@ -133,15 +136,14 @@ func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string)
// `AND status = 'proposed'` makes the move one-way: an answered proposal can // `AND status = 'proposed'` makes the move one-way: an answered proposal can
// never be answered again. // never be answered again.
// //
// AcceptProposedRoutine flips status to 'accepted', links a reminder_id. // AcceptProposedRoutine flips status to 'accepted' and records when. From that
// Returns error if not in 'proposed' status. // timestamp the tick loop owns the schedule: it re-reads accepted rows every
// // tick and nudges when the interval has passed. Returns an error if the row is
// TODO(vikunja#46): the /routines page calls this through ipc to flip status // not in 'proposed' status.
// from the authed surface. func (s *Store) AcceptProposedRoutine(ctx context.Context, id int64, ts time.Time) error {
func (s *Store) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error {
res, err := s.db.ExecContext(ctx, res, err := s.db.ExecContext(ctx,
`UPDATE proposed_routines SET status = 'accepted', reminder_id = ? WHERE id = ? AND status = 'proposed'`, `UPDATE proposed_routines SET status = 'accepted', accepted_ts = ? WHERE id = ? AND status = 'proposed'`,
reminderID, id) ts.UnixMilli(), id)
if err != nil { if err != nil {
return fmt.Errorf("accept proposed routine: %w", err) return fmt.Errorf("accept proposed routine: %w", err)
} }
@@ -152,6 +154,42 @@ func (s *Store) AcceptProposedRoutine(ctx context.Context, id, reminderID int64)
return nil return nil
} }
// ListAcceptedRoutines returns every accepted routine, oldest first. The tick
// loop reads this each tick and decides which ones are due.
func (s *Store) ListAcceptedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts
FROM proposed_routines
WHERE status = 'accepted'
ORDER BY id`)
if err != nil {
return nil, fmt.Errorf("list accepted routines: %w", err)
}
defer rows.Close()
var out []ProposedRoutine
for rows.Next() {
r, err := scanProposedRoutine(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// MarkRoutineFired records that a routine just nudged. The stored time is the
// nudge time, not the time it was theoretically due, so a routine that was
// silent for a while starts its next interval from now — missed occurrences are
// dropped, never replayed as a backlog.
func (s *Store) MarkRoutineFired(ctx context.Context, id int64, ts time.Time) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE proposed_routines SET last_fired_ts = ? WHERE id = ?`,
ts.UnixMilli(), id); err != nil {
return fmt.Errorf("mark routine fired: %w", err)
}
return nil
}
// DismissProposedRoutine flips status to 'dismissed'. Idempotent. // DismissProposedRoutine flips status to 'dismissed'. Idempotent.
func (s *Store) DismissProposedRoutine(ctx context.Context, id int64) error { func (s *Store) DismissProposedRoutine(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, _, err := s.db.ExecContext(ctx,
@@ -168,12 +206,24 @@ func scanProposedRoutine(sc scanner) (ProposedRoutine, error) {
var r ProposedRoutine var r ProposedRoutine
var created int64 var created int64
var reminderID sql.NullInt64 var reminderID sql.NullInt64
if err := sc.Scan(&r.ID, &r.Action, &r.Object, &r.IntervalDays, &r.Status, &created, &reminderID); err != nil { var accepted, lastFired sql.NullInt64
if err := sc.Scan(&r.ID, &r.Action, &r.Object, &r.IntervalDays, &r.Status, &created, &reminderID, &accepted, &lastFired); err != nil {
return ProposedRoutine{}, err return ProposedRoutine{}, err
} }
r.CreatedTs = time.UnixMilli(created).UTC() r.CreatedTs = time.UnixMilli(created).UTC()
if reminderID.Valid { if reminderID.Valid {
r.ReminderID = &reminderID.Int64 r.ReminderID = &reminderID.Int64
} }
r.AcceptedTs = millisToTime(accepted)
r.LastFiredTs = millisToTime(lastFired)
return r, nil return r, nil
} }
// millisToTime turns a nullable unix-millis column into a *time.Time.
func millisToTime(v sql.NullInt64) *time.Time {
if !v.Valid {
return nil
}
t := time.UnixMilli(v.Int64).UTC()
return &t
}
+47 -14
View File
@@ -43,12 +43,7 @@ func TestCreateAndAcceptProposedRoutine(t *testing.T) {
} }
// Accept // Accept
// First create a reminder to link if err := s.AcceptProposedRoutine(ctx, id, now); err != nil {
remID, err := s.CreateReminder(ctx, now.Add(7*24*time.Hour), `{"text":"refill cat water"}`, "0 10 * * 0")
if err != nil {
t.Fatalf("CreateReminder: %v", err)
}
if err := s.AcceptProposedRoutine(ctx, id, remID); err != nil {
t.Fatalf("AcceptProposedRoutine: %v", err) t.Fatalf("AcceptProposedRoutine: %v", err)
} }
@@ -60,8 +55,50 @@ func TestCreateAndAcceptProposedRoutine(t *testing.T) {
if r.Status != "accepted" { if r.Status != "accepted" {
t.Fatalf("want status=accepted, got %s", r.Status) t.Fatalf("want status=accepted, got %s", r.Status)
} }
if r.ReminderID == nil || *r.ReminderID != remID { if r.AcceptedTs == nil || !r.AcceptedTs.Equal(now.Truncate(time.Millisecond)) {
t.Fatalf("want reminder_id=%d, got %v", remID, r.ReminderID) t.Fatalf("want accepted_ts=%v, got %v", now, r.AcceptedTs)
}
if r.LastFiredTs != nil {
t.Fatalf("a freshly accepted routine has not fired yet, got %v", r.LastFiredTs)
}
}
// TestAcceptedRoutineFiredTimestamp — the tick loop's two reads: the accepted
// list, and the last-fired stamp it writes back after a nudge.
func TestAcceptedRoutineFiredTimestamp(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now().UTC().Truncate(time.Millisecond)
id, err := s.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, now)
if err != nil {
t.Fatalf("CreateProposedRoutine: %v", err)
}
if err := s.AcceptProposedRoutine(ctx, id, now); err != nil {
t.Fatalf("AcceptProposedRoutine: %v", err)
}
list, err := s.ListAcceptedRoutines(ctx)
if err != nil {
t.Fatalf("ListAcceptedRoutines: %v", err)
}
if len(list) != 1 || list[0].ID != id {
t.Fatalf("want the one accepted routine, got %+v", list)
}
if list[0].IntervalDays != 3.0 {
t.Fatalf("want interval_days=3, got %v", list[0].IntervalDays)
}
fired := now.Add(3 * 24 * time.Hour)
if err := s.MarkRoutineFired(ctx, id, fired); err != nil {
t.Fatalf("MarkRoutineFired: %v", err)
}
list, err = s.ListAcceptedRoutines(ctx)
if err != nil {
t.Fatalf("ListAcceptedRoutines: %v", err)
}
if list[0].LastFiredTs == nil || !list[0].LastFiredTs.Equal(fired) {
t.Fatalf("want last_fired_ts=%v, got %v", fired, list[0].LastFiredTs)
} }
} }
@@ -165,7 +202,7 @@ func TestDismissedProposedRoutineStaysDismissed(t *testing.T) {
if err := s.DismissProposedRoutine(ctx, id); err != nil { if err := s.DismissProposedRoutine(ctx, id); err != nil {
t.Fatalf("second DismissProposedRoutine: %v", err) t.Fatalf("second DismissProposedRoutine: %v", err)
} }
if err := s.AcceptProposedRoutine(ctx, id, 1); !errors.Is(err, ErrProposedRoutineNotFound) { if err := s.AcceptProposedRoutine(ctx, id, time.Now().UTC()); !errors.Is(err, ErrProposedRoutineNotFound) {
t.Fatalf("want ErrProposedRoutineNotFound accepting a dismissed routine, got %v", err) t.Fatalf("want ErrProposedRoutineNotFound accepting a dismissed routine, got %v", err)
} }
r, err := s.LookupProposedRoutine(ctx, "clean", "litter_box") r, err := s.LookupProposedRoutine(ctx, "clean", "litter_box")
@@ -190,11 +227,7 @@ func TestListProposedRoutinesByStatus(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("CreateProposedRoutine: %v", err) t.Fatalf("CreateProposedRoutine: %v", err)
} }
remID, err := s.CreateReminder(ctx, now.Add(4*24*time.Hour), `{"text":"water plants"}`, "") if err := s.AcceptProposedRoutine(ctx, keep, now); err != nil {
if err != nil {
t.Fatalf("CreateReminder: %v", err)
}
if err := s.AcceptProposedRoutine(ctx, keep, remID); err != nil {
t.Fatalf("AcceptProposedRoutine: %v", err) t.Fatalf("AcceptProposedRoutine: %v", err)
} }
if err := s.DismissProposedRoutine(ctx, drop); err != nil { if err := s.DismissProposedRoutine(ctx, drop); err != nil {
+6 -1
View File
@@ -55,4 +55,9 @@ func spokenDate(dd, mm, yyyy string) string {
} }
func mustInt(s string) int { n, _ := strconv.Atoi(s); return n } func mustInt(s string) int { n, _ := strconv.Atoi(s); return n }
func gap(y string) string { if y == "" { return "" }; return " " + y } func gap(y string) string {
if y == "" {
return ""
}
return " " + y
}