Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0793955896 | |||
| 71a9a59403 | |||
| d3c63e6493 | |||
| c586346a60 |
@@ -127,8 +127,10 @@ func run(args []string) error {
|
|||||||
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
||||||
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
||||||
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap; the daemon does not answer until it finishes)")
|
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap; the daemon does not answer until it finishes)")
|
||||||
|
allowSeed := flag.Bool("allow-seed", false, "enable the backdated seed_event write path (QA only: it lets a caller place a fact in the past and mint a routine the tick loop will then act on; off means the method has nothing to write with)")
|
||||||
flag.CommandLine.Parse(args)
|
flag.CommandLine.Parse(args)
|
||||||
reembedOnStart = *reembed
|
reembedOnStart = *reembed
|
||||||
|
allowSeedOnStart = *allowSeed
|
||||||
cfg, err := config.Load(*cfgPath)
|
cfg, err := config.Load(*cfgPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -338,6 +340,7 @@ func run(args []string) error {
|
|||||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||||
getEvents: intakeEventsFn(evBus),
|
getEvents: intakeEventsFn(evBus),
|
||||||
|
seedStore: seedStoreIfAllowed(st),
|
||||||
}
|
}
|
||||||
if voiceW != nil && voiceW.handler != nil {
|
if voiceW != nil && voiceW.handler != nil {
|
||||||
api := coreAPI.(*daemonAPI)
|
api := coreAPI.(*daemonAPI)
|
||||||
@@ -605,6 +608,7 @@ func run(args []string) error {
|
|||||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||||
getEvents: intakeEventsFn(evBus),
|
getEvents: intakeEventsFn(evBus),
|
||||||
|
seedStore: seedStoreIfAllowed(st),
|
||||||
}
|
}
|
||||||
if voiceW != nil && voiceW.handler != nil {
|
if voiceW != nil && voiceW.handler != nil {
|
||||||
newAPI.chatFn = voiceW.handler.handleText
|
newAPI.chatFn = voiceW.handler.handleText
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// mavend/seed.go — the backdated-fact seam (Vikunja #518).
|
||||||
|
//
|
||||||
|
// The pattern detector needs four events for one action+object, spread by at
|
||||||
|
// least pattern.MinIntervalDays, before it proposes a routine. Nothing could
|
||||||
|
// produce that against a running daemon in one sitting: the only writer is a
|
||||||
|
// fact write at time.Now(), so V-43, V-46, V-247 and V-254 all stopped at the
|
||||||
|
// same missing step and had been stopped there since they were filed.
|
||||||
|
//
|
||||||
|
// This is the write path that unblocks them, and it is deliberately the narrow
|
||||||
|
// one. It takes a fact, not an event, so pattern.Extract runs for real and a
|
||||||
|
// key the extractor ignores seeds nothing. It runs detectAndPropose, so what a
|
||||||
|
// seed proves is the daemon's own wiring rather than the detector in isolation
|
||||||
|
// — which is what an eval-lab fixture would have proved, and is not what those
|
||||||
|
// four tasks doubt.
|
||||||
|
//
|
||||||
|
// It is off unless mavend was started with -allow-seed, and AuthStepUp in the
|
||||||
|
// authority table besides. See ipc.SeedEventReq and auth.Requirement.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/pattern"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errSeedDisabled — what a caller gets on an ordinary box. Named rather than
|
||||||
|
// inline so the mavweb route can tell "not allowed here" apart from "the seed
|
||||||
|
// ran and the extractor declined", which look the same to a reader otherwise.
|
||||||
|
var errSeedDisabled = errors.New("mavend: seeding is off (start with -allow-seed)")
|
||||||
|
|
||||||
|
// seedSource — every seeded fact carries this, and no other writer uses it.
|
||||||
|
// The point is that seeded data stays identifiable forever: a fact that came
|
||||||
|
// from a QA sitting must never be mistaken for something he said, either by a
|
||||||
|
// person reading /history or by the wipe in V-494 when it lands.
|
||||||
|
const seedSource = "seed:qa"
|
||||||
|
|
||||||
|
// seedStoreIfAllowed returns st only when -allow-seed was passed, and logs the
|
||||||
|
// fact loudly when it does. A box that can rewrite its own past should say so
|
||||||
|
// in its boot log, so nobody reads a seeded routine months later as evidence of
|
||||||
|
// something he actually did.
|
||||||
|
func seedStoreIfAllowed(st *store.Store) *store.Store {
|
||||||
|
if !allowSeedOnStart {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("seed: -allow-seed is ON — backdated fact writes are permitted under source %q (Vikunja #518)", seedSource)
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedEvent writes the fact at the caller's timestamp, extracts an event from
|
||||||
|
// it, and runs the same detect-and-propose step the voice path runs.
|
||||||
|
//
|
||||||
|
// Best-effort is NOT the shape here, unlike detectPattern: a seed that half
|
||||||
|
// worked is a QA result nobody can trust, so every step reports its own
|
||||||
|
// failure. Extraction declining is not a failure — it is the extractor's
|
||||||
|
// documented answer for a value outside its lexicon, and Extracted says so.
|
||||||
|
func (d *daemonAPI) SeedEvent(ctx context.Context, req ipc.SeedEventReq) (ipc.SeedEventResp, error) {
|
||||||
|
if d.seedStore == nil {
|
||||||
|
return ipc.SeedEventResp{}, errSeedDisabled
|
||||||
|
}
|
||||||
|
if req.Key == "" || req.Value == "" {
|
||||||
|
return ipc.SeedEventResp{}, errors.New("mavend: seed needs a key and a value")
|
||||||
|
}
|
||||||
|
if req.Ts.IsZero() {
|
||||||
|
return ipc.SeedEventResp{}, errors.New("mavend: seed needs an explicit timestamp")
|
||||||
|
}
|
||||||
|
|
||||||
|
// No Subject, unlike the voice path: a seeded key must not queue a Nexus
|
||||||
|
// resolution. QA data has no business reaching the ecosystem.
|
||||||
|
factID, err := d.seedStore.WriteFact(ctx, req.Ts, store.KindSelf, req.Key, req.Value, seedSource, 1.0, sql.NullInt64{})
|
||||||
|
if err != nil {
|
||||||
|
return ipc.SeedEventResp{}, fmt.Errorf("seed write fact: %w", err)
|
||||||
|
}
|
||||||
|
resp := ipc.SeedEventResp{FactID: factID}
|
||||||
|
|
||||||
|
ev := pattern.Extract(factID, req.Key, req.Value, req.Ts)
|
||||||
|
if ev == nil {
|
||||||
|
// The fact is written and stays written. Saying so matters: a caller
|
||||||
|
// that assumed a seed always produces an event would otherwise read
|
||||||
|
// four silent successes and conclude the detector is broken.
|
||||||
|
log.Printf("seed: %s=%s wrote fact %d, no event (value outside the action lexicon)", req.Key, req.Value, factID)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
resp.Extracted, resp.Action, resp.Object = true, ev.Action, ev.Object
|
||||||
|
|
||||||
|
eventID, err := d.seedStore.CreateEvent(ctx, factID, ev.Action, ev.Object, req.Ts)
|
||||||
|
if err != nil {
|
||||||
|
return resp, fmt.Errorf("seed create event: %w", err)
|
||||||
|
}
|
||||||
|
resp.EventID = eventID
|
||||||
|
|
||||||
|
r, routineID, err := detectAndPropose(ctx, d.seedStore, ev.Action, ev.Object, req.Ts)
|
||||||
|
if err != nil {
|
||||||
|
return resp, fmt.Errorf("seed detect: %w", err)
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
return resp, nil // too few events yet, too irregular, or already decided
|
||||||
|
}
|
||||||
|
resp.Proposed, resp.RoutineID, resp.IntervalDays = true, routineID, r.IntervalDays
|
||||||
|
log.Printf("seed: proposed routine %d — %s/%s every %.1f days", routineID, r.Action, r.Object, r.IntervalDays)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Off is the default and it must mean "nothing to write with", not "permission
|
||||||
|
// to refuse later". A daemonAPI with no seedStore writes no fact at all.
|
||||||
|
func TestSeedRefusedWithoutTheFlag(t *testing.T) {
|
||||||
|
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}}
|
||||||
|
_, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||||
|
Key: "cat_water_fountain", Value: "заправил", Ts: time.Now(),
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("seed succeeded with no seedStore")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "-allow-seed") {
|
||||||
|
t.Errorf("error does not name the flag: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of the task: four seeds spread past the detector's floor
|
||||||
|
// produce a proposal against the real daemon path, which is what nobody could
|
||||||
|
// do before (Vikunja #518). Three seeds must NOT propose — MinEvents is four,
|
||||||
|
// and a test that only checked the happy end would pass on an off-by-one.
|
||||||
|
func TestSeedFourEventsProposesARoutine(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
var last ipc.SeedEventResp
|
||||||
|
// Oldest first, three hours apart — past MinIntervalDays (two hours).
|
||||||
|
for i := 3; i >= 0; i-- {
|
||||||
|
var err error
|
||||||
|
last, err = d.SeedEvent(ctx, ipc.SeedEventReq{
|
||||||
|
Key: "cat_water_fountain",
|
||||||
|
Value: "заправил",
|
||||||
|
Ts: now.Add(-time.Duration(i) * 3 * time.Hour),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if !last.Extracted {
|
||||||
|
t.Fatalf("seed %d: no event extracted from a lexicon verb", i)
|
||||||
|
}
|
||||||
|
if i > 0 && last.Proposed {
|
||||||
|
t.Fatalf("proposed after only %d events, MinEvents is 4", 4-i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !last.Proposed {
|
||||||
|
t.Fatal("four spaced events did not propose a routine")
|
||||||
|
}
|
||||||
|
if last.Action != "refill" || last.Object != "cat_water_fountain" {
|
||||||
|
t.Errorf("wrong pair: %s/%s", last.Action, last.Object)
|
||||||
|
}
|
||||||
|
if last.IntervalDays < 0.1 {
|
||||||
|
t.Errorf("interval %v — the detector saw a burst, not a rhythm", last.IntervalDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The proposal is readable through the same list the /routines page uses,
|
||||||
|
// which is the wiring an eval-lab fixture would not have proved.
|
||||||
|
proposed, err := d.seedStore.ListProposedRoutines(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(proposed) != 1 {
|
||||||
|
t.Fatalf("expected 1 proposed routine, got %d", len(proposed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A value outside the action lexicon writes the fact and says it seeded
|
||||||
|
// nothing. Silence here would read as four working seeds and a broken
|
||||||
|
// detector.
|
||||||
|
func TestSeedReportsWhenExtractionDeclines(t *testing.T) {
|
||||||
|
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||||
|
resp, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||||
|
Key: "mood", Value: "ok", Ts: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
if resp.FactID == 0 {
|
||||||
|
t.Error("fact was not written")
|
||||||
|
}
|
||||||
|
if resp.Extracted || resp.EventID != 0 || resp.Proposed {
|
||||||
|
t.Errorf("claimed an event for a non-action value: %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A seed with no timestamp is refused rather than defaulting to now: the only
|
||||||
|
// reason this seam exists is the caller choosing when, so a zero Ts is a bug in
|
||||||
|
// the caller and must not silently write a fact at the wrong time.
|
||||||
|
func TestSeedRequiresAnExplicitTimestamp(t *testing.T) {
|
||||||
|
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||||
|
if _, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||||
|
Key: "cat_water_fountain", Value: "заправил",
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("seed accepted a zero timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
|
// daemonAPI wraps a store-backed CoreAPI and overrides TickTrace with the
|
||||||
@@ -22,6 +23,11 @@ type daemonAPI struct {
|
|||||||
chatFn func(ctx context.Context, conversation, text string) string
|
chatFn func(ctx context.Context, conversation, text string) string
|
||||||
getMCPServers func() []ipc.MCPServerStatus
|
getMCPServers func() []ipc.MCPServerStatus
|
||||||
getEvents func(n int) []ipc.IntakeEvent
|
getEvents func(n int) []ipc.IntakeEvent
|
||||||
|
// seedStore — non-nil ONLY when mavend was started with -allow-seed. It is
|
||||||
|
// the whole off-switch for the backdated write path (Vikunja #518), and it
|
||||||
|
// is a store rather than a bool so that leaving the flag off means the
|
||||||
|
// method has nothing to write with, not merely permission to refuse.
|
||||||
|
seedStore *store.Store
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
|
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
|
||||||
|
|||||||
@@ -552,6 +552,11 @@ func repairFactVectors(dataStore *store.Store, emb router.Embedder) {
|
|||||||
// runReembed.
|
// runReembed.
|
||||||
var reembedOnStart bool
|
var reembedOnStart bool
|
||||||
|
|
||||||
|
// allowSeedOnStart is the -allow-seed flag (set in run()). Opt-in, and the
|
||||||
|
// default is the one that matters: a box nobody is testing has no live path to
|
||||||
|
// write a fact into the past. See seed.go and Vikunja #518.
|
||||||
|
var allowSeedOnStart bool
|
||||||
|
|
||||||
// checkStoredEmbedder compares the embedder we just loaded with the one that
|
// checkStoredEmbedder compares the embedder we just loaded with the one that
|
||||||
// wrote the vectors already in the DB (Vikunja #378).
|
// wrote the vectors already in the DB (Vikunja #378).
|
||||||
//
|
//
|
||||||
|
|||||||
+78
-20
@@ -1094,34 +1094,53 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, se
|
|||||||
var msg string
|
var msg string
|
||||||
if r.Method == http.MethodPost {
|
if r.Method == http.MethodPost {
|
||||||
action := r.FormValue("action")
|
action := r.FormValue("action")
|
||||||
idStr := r.FormValue("id")
|
// "seed" is the one action with no routine to act on — it is what
|
||||||
var rid int64
|
// MAKES a routine (Vikunja #518), so it runs before the id parse. It
|
||||||
if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 {
|
// lives on this route rather than a page of its own because it is
|
||||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
// already the step-up-gated surface for this table, and a second gated
|
||||||
return
|
// surface is a second thing to get wrong.
|
||||||
}
|
if action == "seed" {
|
||||||
switch action {
|
|
||||||
case "accept":
|
|
||||||
if !stepUpOK(session, requireStepUp) {
|
if !stepUpOK(session, requireStepUp) {
|
||||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := acceptRoutine(ctx, core, rid); err != nil {
|
out, err := seedRoutineEvent(ctx, core, r)
|
||||||
log.Printf("routines: accept %d: %v", rid, err)
|
if err != nil {
|
||||||
http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway)
|
log.Printf("routines: seed: %v", err)
|
||||||
|
http.Error(w, "seed failed: "+err.Error(), http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg = "accepted routine — maven will remind you"
|
msg = out
|
||||||
case "dismiss":
|
} else {
|
||||||
if err := core.DismissProposedRoutine(ctx, rid); err != nil {
|
idStr := r.FormValue("id")
|
||||||
log.Printf("routines: dismiss %d: %v", rid, err)
|
var rid int64
|
||||||
http.Error(w, "dismiss failed: "+err.Error(), http.StatusBadGateway)
|
if n, _ := fmt.Sscanf(idStr, "%d", &rid); n != 1 {
|
||||||
|
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case "accept":
|
||||||
|
if !stepUpOK(session, requireStepUp) {
|
||||||
|
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg = "accepted routine — maven will remind you"
|
||||||
|
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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg = "dismissed routine"
|
||||||
|
default:
|
||||||
|
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
msg = "dismissed routine"
|
|
||||||
default:
|
|
||||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
proposed, err := core.ListProposedRoutines(ctx)
|
proposed, err := core.ListProposedRoutines(ctx)
|
||||||
@@ -1158,6 +1177,45 @@ func toRoutineViews(rs []ipc.ProposedRoutine) []routineView {
|
|||||||
// may do it (Vikunja #367): accepting gives the tick loop a standing new
|
// may do it (Vikunja #367): accepting gives the tick loop a standing new
|
||||||
// reason to speak, which DESIGN.md puts at layer 3, and the button here is
|
// reason to speak, which DESIGN.md puts at layer 3, and the button here is
|
||||||
// behind step-up. Voice can park the question and dismiss, never accept.
|
// behind step-up. Voice can park the question and dismiss, never accept.
|
||||||
|
// seedRoutineEvent drives one backdated fact write through core (Vikunja #518),
|
||||||
|
// so the pattern detector can be exercised against a running daemon instead of
|
||||||
|
// over real days. Refused unless mavend was started with -allow-seed; on an
|
||||||
|
// ordinary box the error says so and nothing is written.
|
||||||
|
//
|
||||||
|
// Takes "ago" rather than an absolute timestamp — hours before now, as a float
|
||||||
|
// so a QA sitting can space four seeds three hours apart without doing clock
|
||||||
|
// arithmetic. The detector's floor is two hours, and "0" is a legal answer
|
||||||
|
// meaning now.
|
||||||
|
func seedRoutineEvent(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
|
||||||
|
key := strings.TrimSpace(r.FormValue("key"))
|
||||||
|
value := strings.TrimSpace(r.FormValue("value"))
|
||||||
|
if key == "" || value == "" {
|
||||||
|
return "", errors.New("seed needs a key and a value")
|
||||||
|
}
|
||||||
|
agoHours, err := strconv.ParseFloat(strings.TrimSpace(r.FormValue("ago")), 64)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("seed: bad ago (hours before now): %w", err)
|
||||||
|
}
|
||||||
|
if agoHours < 0 {
|
||||||
|
return "", errors.New("seed: ago is hours BEFORE now, so it cannot be negative")
|
||||||
|
}
|
||||||
|
resp, err := core.SeedEvent(ctx, ipc.SeedEventReq{
|
||||||
|
Key: key,
|
||||||
|
Value: value,
|
||||||
|
Ts: time.Now().Add(-time.Duration(agoHours * float64(time.Hour))),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !resp.Extracted {
|
||||||
|
return fmt.Sprintf("wrote fact %d, but %q is not in the action lexicon — no event, no pattern", resp.FactID, value), nil
|
||||||
|
}
|
||||||
|
if !resp.Proposed {
|
||||||
|
return fmt.Sprintf("seeded %s/%s (fact %d, event %d) — not enough yet to propose", resp.Action, resp.Object, resp.FactID, resp.EventID), nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("seeded %s/%s and PROPOSED routine %d, every %.1f days", resp.Action, resp.Object, resp.RoutineID, resp.IntervalDays), nil
|
||||||
|
}
|
||||||
|
|
||||||
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
|
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
|
||||||
proposed, err := core.ListProposedRoutines(ctx)
|
proposed, err := core.ListProposedRoutines(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -91,6 +91,19 @@ func Requirement(m ipc.Method) Authority {
|
|||||||
// can do is make Maven stop recognising someone, which is the state the
|
// can do is make Maven stop recognising someone, which is the state the
|
||||||
// box ships in anyway.
|
// box ships in anyway.
|
||||||
return AuthWrite
|
return AuthWrite
|
||||||
|
case ipc.MethodSeedEvent:
|
||||||
|
// The one backdating write path in the tree (Vikunja #518). AuthStepUp,
|
||||||
|
// the same rung as mutating the tool allowlist, and for a reason that is
|
||||||
|
// not about privilege: every other write records when something actually
|
||||||
|
// happened, and this one asserts it. A caller who can place a fact in the
|
||||||
|
// past can manufacture a routine Maven will then act on forever, which is
|
||||||
|
// the tick loop obeying evidence nobody produced.
|
||||||
|
//
|
||||||
|
// Step-up is not the real gate and is not meant to be. mavend refuses the
|
||||||
|
// method entirely unless started with -allow-seed, so the ordinary state
|
||||||
|
// of the box is that no gesture reaches it. This rung is what stops a
|
||||||
|
// module from calling it on a box where QA left the flag on.
|
||||||
|
return AuthStepUp
|
||||||
case ipc.MethodWriteFact:
|
case ipc.MethodWriteFact:
|
||||||
return AuthWrite
|
return AuthWrite
|
||||||
case ipc.MethodIngestMail:
|
case ipc.MethodIngestMail:
|
||||||
|
|||||||
@@ -184,6 +184,48 @@ type CaptureTaskResp struct {
|
|||||||
Promoted bool `json:"promoted,omitempty"`
|
Promoted bool `json:"promoted,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SeedEventReq — write one fact at a caller-supplied timestamp and run the
|
||||||
|
// pattern path over it, so a recurring routine can be produced on demand
|
||||||
|
// instead of over real days (Vikunja #518).
|
||||||
|
//
|
||||||
|
// This is the ONLY backdating write path in the tree, and it exists for one
|
||||||
|
// reason: the detector needs four events spread over hours before it proposes
|
||||||
|
// anything, so V-43, V-46, V-247 and V-254 could not be verified against a
|
||||||
|
// running daemon at all. A store fixture would have exercised the detector
|
||||||
|
// without the wiring those tasks doubt.
|
||||||
|
//
|
||||||
|
// Two things hold it shut. It is AuthStepUp in the authority table, the same
|
||||||
|
// rung as mutating the tool allowlist. And mavend refuses it outright unless
|
||||||
|
// started with -allow-seed, so a box nobody is testing carries no live
|
||||||
|
// backdating path even for a caller who cleared the gate.
|
||||||
|
//
|
||||||
|
// Key and Value are a fact, not an event: extraction runs for real, so a key
|
||||||
|
// the extractor ignores seeds nothing and says so. That is deliberate — a
|
||||||
|
// seam that accepted action and object directly would let QA prove a detector
|
||||||
|
// against events no utterance could ever produce.
|
||||||
|
type SeedEventReq struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Ts time.Time `json:"ts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedEventResp — what the seed produced. Extracted is false when the fact was
|
||||||
|
// written but yielded no event, which is the extractor declining rather than a
|
||||||
|
// failure. Proposed is true only when this seed completed a pattern; the first
|
||||||
|
// three seeds of a run return false with no routine.
|
||||||
|
type SeedEventResp struct {
|
||||||
|
FactID int64 `json:"fact_id"`
|
||||||
|
EventID int64 `json:"event_id,omitempty"`
|
||||||
|
Extracted bool `json:"extracted"`
|
||||||
|
Action string `json:"action,omitempty"`
|
||||||
|
Object string `json:"object,omitempty"`
|
||||||
|
Proposed bool `json:"proposed"`
|
||||||
|
RoutineID int64 `json:"routine_id,omitempty"`
|
||||||
|
// IntervalDays — the median the detector settled on, echoed so QA can
|
||||||
|
// check it against the spacing it asked for.
|
||||||
|
IntervalDays float64 `json:"interval_days,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// IngestMailReq — one message a mail reader has fetched, handed to core for
|
// IngestMailReq — one message a mail reader has fetched, handed to core for
|
||||||
// extraction (Vikunja #246).
|
// extraction (Vikunja #246).
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -487,6 +487,14 @@ func (c *Client) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, e
|
|||||||
return r.Routines, nil
|
return r.Routines, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
|
||||||
|
var r SeedEventResp
|
||||||
|
if err := c.call(ctx, MethodSeedEvent, req, &r); err != nil {
|
||||||
|
return SeedEventResp{}, err
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
func (c *Client) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||||
var r CaptureTaskResp
|
var r CaptureTaskResp
|
||||||
if err := c.call(ctx, MethodCaptureTask, req, &r); err != nil {
|
if err := c.call(ctx, MethodCaptureTask, req, &r); err != nil {
|
||||||
|
|||||||
@@ -110,6 +110,13 @@ type RoutineAPI interface {
|
|||||||
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
// loop takes the schedule from there — no reminder is created (Vikunja #366).
|
||||||
AcceptProposedRoutine(ctx context.Context, id int64) error
|
AcceptProposedRoutine(ctx context.Context, id int64) error
|
||||||
|
|
||||||
|
// SeedEvent writes a backdated fact and runs extraction and detection over
|
||||||
|
// it, so a proposal can be produced in one sitting rather than over real
|
||||||
|
// days (Vikunja #518). See SeedEventReq for why this exists and what keeps
|
||||||
|
// it shut. Daemon-computed, like MorningStatus — the store adapter refuses
|
||||||
|
// it, because the detect-and-propose step lives in mavend.
|
||||||
|
SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error)
|
||||||
|
|
||||||
// MorningStatus returns each configured morning routine's current
|
// MorningStatus returns each configured morning routine's current
|
||||||
// checklist state (see internal/morning): active today/now, which items
|
// checklist state (see internal/morning): active today/now, which items
|
||||||
// are done, which are still missing.
|
// are done, which are still missing.
|
||||||
|
|||||||
@@ -524,6 +524,9 @@ var methodTable = map[Method]handlerFunc{
|
|||||||
MethodCaptureTask: withParams(func(ctx context.Context, api CoreAPI, p CaptureTaskReq) (CaptureTaskResp, error) {
|
MethodCaptureTask: withParams(func(ctx context.Context, api CoreAPI, p CaptureTaskReq) (CaptureTaskResp, error) {
|
||||||
return api.CaptureTask(ctx, p)
|
return api.CaptureTask(ctx, p)
|
||||||
}),
|
}),
|
||||||
|
MethodSeedEvent: withParams(func(ctx context.Context, api CoreAPI, p SeedEventReq) (SeedEventResp, error) {
|
||||||
|
return api.SeedEvent(ctx, p)
|
||||||
|
}),
|
||||||
MethodListTasks: withParams(func(ctx context.Context, api CoreAPI, p listTasksReq) (listTasksResp, error) {
|
MethodListTasks: withParams(func(ctx context.Context, api CoreAPI, p listTasksReq) (listTasksResp, error) {
|
||||||
out, err := api.ListTasks(ctx, p.Status)
|
out, err := api.ListTasks(ctx, p.Status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -265,6 +265,14 @@ func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
|||||||
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
|
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SeedEvent — same shape as MorningStatus: writing the fact is a store call,
|
||||||
|
// but extraction and detect-and-propose live in mavend, and a seed that wrote
|
||||||
|
// the fact without running them would be the one thing this seam must not be,
|
||||||
|
// a way to prove a detector that never ran (Vikunja #518).
|
||||||
|
func (a *storeAPI) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
|
||||||
|
return SeedEventResp{}, errors.New("store: seed event not available via direct store API")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
||||||
return nil, errors.New("store: morning status not available via direct store API")
|
return nil, errors.New("store: morning status not available via direct store API")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,9 @@ func (UnimplementedCoreAPI) DeleteTool(ctx context.Context, name string) error {
|
|||||||
func (UnimplementedCoreAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
func (UnimplementedCoreAPI) CaptureTask(ctx context.Context, req CaptureTaskReq) (CaptureTaskResp, error) {
|
||||||
return CaptureTaskResp{}, ErrNotImplemented
|
return CaptureTaskResp{}, ErrNotImplemented
|
||||||
}
|
}
|
||||||
|
func (UnimplementedCoreAPI) SeedEvent(ctx context.Context, req SeedEventReq) (SeedEventResp, error) {
|
||||||
|
return SeedEventResp{}, ErrNotImplemented
|
||||||
|
}
|
||||||
func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
func (UnimplementedCoreAPI) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||||
return nil, ErrNotImplemented
|
return nil, ErrNotImplemented
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const (
|
|||||||
MethodListSpeakers Method = "list_speakers"
|
MethodListSpeakers Method = "list_speakers"
|
||||||
MethodForgetSpeaker Method = "forget_speaker"
|
MethodForgetSpeaker Method = "forget_speaker"
|
||||||
MethodRecentEvents Method = "recent_events"
|
MethodRecentEvents Method = "recent_events"
|
||||||
|
MethodSeedEvent Method = "seed_event"
|
||||||
|
|
||||||
// MethodPing — liveness, and the only method that answers in locked mode
|
// MethodPing — liveness, and the only method that answers in locked mode
|
||||||
// without a passkey assertion. It reaches no store, takes no arguments and
|
// without a passkey assertion. It reaches no store, takes no arguments and
|
||||||
|
|||||||
Reference in New Issue
Block a user