diff --git a/cmd/mavend/intake.go b/cmd/mavend/intake.go index 0e7542d..ba4954a 100644 --- a/cmd/mavend/intake.go +++ b/cmd/mavend/intake.go @@ -40,6 +40,7 @@ package main import ( "context" + "encoding/json" "log" "strings" "time" @@ -54,7 +55,12 @@ import ( // off (a negative config.intake_journal). nil is the "behave exactly as before" // value all the way down: no decorator, no ring, no /events rows. func newEventBus(cfg *config.Config) *event.Bus { - if cfg == nil || cfg.IntakeJournal < 0 { + if cfg == nil { + // No config at all is a test, not an operator decision. Saying "off" + // here was noise in every suite that passes nil. + return nil + } + if cfg.IntakeJournal < 0 { log.Printf("intake journal: off (intake_journal < 0)") return nil } @@ -85,6 +91,7 @@ func intakeEventsFn(bus *event.Bus) func(n int) []ipc.IntakeEvent { Body: e.Body, Priority: e.Priority, OccurredAt: e.OccurredAt, + NoticedAt: e.NoticedAt, }) } return out @@ -120,18 +127,34 @@ func (a *intakeAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, if err != nil { return id, err } + if selfWrite(req) { + // Maven's own bookkeeping is not something that arrived. The feed + // watermark, the crawl hash, the praxis trace of an act she performed + // and a quiet-hours toggle he pressed all used to sit on a page headed + // "everything that arrived", and on a cold start a handful of feeds + // could evict real intake behind their marks. + return id, nil + } // OccurredAt is req.Ts, not now: mavpoll's wg read carries the handshake // instant and the ambient path carries the meeting's start. Flattening // those to notice-time would make the journal lie about when things // happened, which is the one thing it is for. + title := req.Key + if req.VoidsID != nil { + // A retraction is not a reading. Without this it published an envelope + // indistinguishable from a fresh value for the same key, on a page + // whose whole job is "what came in". + title = "отмена: " + req.Key + } a.bus.Publish(event.Event{ Source: req.Source, Kind: event.SourceKind(req.Source, event.KindFact), - Title: req.Key, + Title: title, Body: req.Value, Priority: factPriority(req), OccurredAt: req.Ts, EntityIDs: entityIDs(req.Subject), + Payload: factPayload(req), }, a.now()) return id, nil } @@ -201,17 +224,69 @@ func publishableTask(t store.Task, now time.Time) event.Event { } } +// selfWrite reports whether a fact write is Maven describing her own state +// rather than something arriving from outside. The store's fact kinds are +// 'self', 'env' and 'config'; 'config' is where every watermark and toggle +// lands, and the praxis trace is an audit record of an act she performed, which +// is the same class of thing under an 'env' kind. +func selfWrite(req ipc.WriteFactReq) bool { + switch req.Kind { + case "config", "system": + return true + } + return strings.HasPrefix(req.Source, "praxis:trace") +} + // factPriority is the attention hint for a fact write. Deliberately crude: // a low-confidence inference (the ambient notification path writes below 1.0) -// is worth less attention than a read he or a credentialled poller made, and -// nothing else is distinguishable from here. +// is worth less attention than a read he or a credentialled poller made, and a +// retraction is a correction rather than news. +// +// Confidence is NOT recoverable from this, which is why the number itself goes +// into Payload: three display buckets must not be the only surviving trace of +// the distinction internal/calendar went out of its way to keep. func factPriority(req ipc.WriteFactReq) string { + if req.VoidsID != nil { + return event.PriorityLow + } if req.Confidence > 0 && req.Confidence < 1.0 { return event.PriorityLow } return event.PriorityNormal } +// factDetail is the fact-shaped Payload: the fields the envelope's own flat +// shape cannot carry, kept so a reader can tell an inference from a +// credentialled read, and "nobody said" from "certain". +type factDetail struct { + // FactKind — the fact's own kind ('self', 'env', 'config'), a different + // taxonomy from Event.Kind. + FactKind string `json:"fact_kind,omitempty"` + // Confidence — the number itself, so an inference stays distinguishable + // from a credentialled read. nil when the writer set none, which the ipc + // layer rejects today; the pointer keeps "nobody said" and "certain" from + // collapsing into each other the way the priority bucket does. + Confidence *float64 `json:"confidence,omitempty"` + // VoidsID — the fact this one retracts. + VoidsID *int64 `json:"voids_id,omitempty"` +} + +func factPayload(req ipc.WriteFactReq) json.RawMessage { + d := factDetail{FactKind: req.Kind, VoidsID: req.VoidsID} + if req.Confidence != 0 { + c := req.Confidence + d.Confidence = &c + } + if d.FactKind == "" && d.Confidence == nil && d.VoidsID == nil { + return nil + } + b, err := json.Marshal(d) + if err != nil { + return nil + } + return b +} + // entityIDs turns a fact's free-text Subject into the EntityIDs slot when it // already looks resolved. Intake runs BEFORE the fact enrichment worker // resolves a subject against Nexus, so this is almost always empty — the slot diff --git a/cmd/mavend/intake_test.go b/cmd/mavend/intake_test.go index 1105628..a73103d 100644 --- a/cmd/mavend/intake_test.go +++ b/cmd/mavend/intake_test.go @@ -2,7 +2,9 @@ package main import ( "context" + "encoding/json" "errors" + "strings" "testing" "time" @@ -176,3 +178,110 @@ func TestDaemonAPIRecentEventsEmptyWithoutABus(t *testing.T) { t.Errorf("got %d events, want none", len(got)) } } + +// Maven's own bookkeeping is not intake. The feed watermark, the crawl hash, +// the praxis trace of an act she performed and the quiet-hours toggle he +// pressed all landed on a page headed "everything that arrived", and on a cold +// start a handful of feeds could evict real intake behind their marks. +func TestIntakeSkipsHerOwnBookkeeping(t *testing.T) { + api, bus := newIntakeTestAPI(t) + ctx := context.Background() + for _, req := range []ipc.WriteFactReq{ + {Ts: intakeNow, Kind: "config", Key: "rss:latest:tech", Value: "2026-08-01T09:00:00Z", Source: "poll:rss", Confidence: 1.0}, + {Ts: intakeNow, Kind: "config", Key: "crawl:hash:kernel", Value: "deadbeef", Source: "poll:crawl", Confidence: 1.0}, + {Ts: intakeNow, Kind: "config", Key: "quiet_hours", Value: "true", Source: "tap:voice", Confidence: 1.0}, + {Ts: intakeNow, Kind: "env", Key: "praxis:list_attention", Value: "ok", Source: "praxis:trace", Confidence: 1.0}, + } { + if _, err := api.WriteFact(ctx, req); err != nil { + t.Fatalf("WriteFact(%s): %v", req.Key, err) + } + } + if n := bus.Len(); n != 0 { + t.Fatalf("journalled %d bookkeeping writes, want 0: %+v", n, bus.Recent(0)) + } + // A real arrival under the same decorator still lands. + if _, err := api.WriteFact(ctx, ipc.WriteFactReq{ + Ts: intakeNow, Kind: "env", Key: "spend_today", Value: "1200", + Source: "poll:zenmoney", Confidence: 1.0, + }); err != nil { + t.Fatal(err) + } + if bus.Len() != 1 { + t.Fatalf("a real intake write was dropped: %+v", bus.Recent(0)) + } +} + +// Confidence is the distinction between an inference and a credentialled read, +// and the three-value priority bucket cannot carry it: unset and 1.0 land in +// the same bucket, and 0.6 is gone entirely once mapped. Payload keeps it. +func TestIntakeCarriesConfidenceAndFactKind(t *testing.T) { + api, bus := newIntakeTestAPI(t) + ctx := context.Background() + if _, err := api.WriteFact(ctx, ipc.WriteFactReq{ + Ts: intakeNow, Kind: "env", Key: "calendar_event_x", Value: "18:00 планёрка", + Source: "ambient:notif", Confidence: 0.6, + }); err != nil { + t.Fatal(err) + } + if _, err := api.WriteFact(ctx, ipc.WriteFactReq{ + Ts: intakeNow, Kind: "self", Key: "mood", Value: "ok", Source: "tap:web", Confidence: 1.0, + }); err != nil { + t.Fatal(err) + } + got := bus.Recent(0) + if len(got) != 2 { + t.Fatalf("journal has %d entries, want 2", len(got)) + } + var relayed, stated factDetail + if err := json.Unmarshal(got[1].Payload, &relayed); err != nil { + t.Fatalf("payload: %v", err) + } + if relayed.Confidence == nil || *relayed.Confidence != 0.6 { + t.Errorf("confidence = %v, want 0.6 recoverable from the payload", relayed.Confidence) + } + if relayed.FactKind != "env" { + t.Errorf("fact_kind = %q, want env", relayed.FactKind) + } + // Both writes land in PriorityNormal or PriorityLow buckets that cannot be + // told apart from the outside; the payload is where the two numbers stay + // distinguishable. + if err := json.Unmarshal(got[0].Payload, &stated); err != nil { + t.Fatalf("payload: %v", err) + } + if stated.Confidence == nil || *stated.Confidence != 1.0 || stated.FactKind != "self" { + t.Errorf("payload = %+v, want confidence 1.0 and fact_kind self", stated) + } +} + +// A retraction is not an observation. It used to publish an envelope +// indistinguishable from a fresh reading of the same key. +func TestIntakeMarksARetraction(t *testing.T) { + api, bus := newIntakeTestAPI(t) + ctx := context.Background() + id, err := api.WriteFact(ctx, ipc.WriteFactReq{ + Ts: intakeNow, Kind: "env", Key: "weight", Value: "82", Source: "tap:web", Confidence: 1.0, + }) + if err != nil { + t.Fatal(err) + } + if _, err := api.WriteFact(ctx, ipc.WriteFactReq{ + Ts: intakeNow, Kind: "env", Key: "weight", Value: "81", Source: "tap:web", + Confidence: 1.0, VoidsID: &id, + }); err != nil { + t.Fatal(err) + } + e := bus.Recent(1)[0] + if e.Priority != event.PriorityLow { + t.Errorf("priority = %q, want low for a correction", e.Priority) + } + if !strings.HasPrefix(e.Title, "отмена:") { + t.Errorf("title = %q, want it marked as a retraction", e.Title) + } + var d factDetail + if err := json.Unmarshal(e.Payload, &d); err != nil { + t.Fatal(err) + } + if d.VoidsID == nil || *d.VoidsID != id { + t.Errorf("voids_id = %v, want %d", d.VoidsID, id) + } +} diff --git a/cmd/mavweb/events.html b/cmd/mavweb/events.html index df655e9..a344c98 100644 --- a/cmd/mavweb/events.html +++ b/cmd/mavweb/events.html @@ -1,17 +1,21 @@ {{template "shellTop" "events"}}
| when | source | kind | pri | what | detail | |
|---|---|---|---|---|---|---|
| noticed | happened | source | kind | pri | what | detail |
| {{.OccurredAt.Format "02.01 15:04:05"}} | +{{.NoticedAt.Format "02.01 15:04:05"}} | +{{.OccurredAt.Format "02.01 15:04:05"}} | {{.Source}} | {{.Kind}} | {{.Priority}} | diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 17e49af..aac1666 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -318,14 +318,14 @@ var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shel // human surface is here (they ship no web UI of their own). var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).Parse(shellTopHTML + ecosystemHTML + shellBottomHTML)) -// morningTmpl — read-only view of today's checklist state per configured -// morning routine (internal/morning). Same shape as trace.html: a plain -// server-rendered page, refreshed on reload — no live-update loop, since -// checklist state changes on the scale of minutes, not seconds. // eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same // shape as trace.html and morning.html: server-rendered, refreshed on reload. var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellTopHTML + eventsHTML + shellBottomHTML)) +// morningTmpl — read-only view of today's checklist state per configured +// morning routine (internal/morning). Same shape as trace.html: a plain +// server-rendered page, refreshed on reload — no live-update loop, since +// checklist state changes on the scale of minutes, not seconds. var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML)) func noCache(h http.Handler) http.Handler { diff --git a/internal/event/bus.go b/internal/event/bus.go index 5b29a16..72d0c0c 100644 --- a/internal/event/bus.go +++ b/internal/event/bus.go @@ -95,8 +95,14 @@ func (b *Bus) Subscribe(fn func(Event)) { b.subs = append(b.subs, fn) } -// Recent returns up to limit events, newest first. limit <= 0 returns -// everything held. Safe on a nil receiver (returns nil). +// Recent returns up to limit events, most recently NOTICED first. limit <= 0 +// returns everything held. Safe on a nil receiver (returns nil). +// +// The order is the ring's insertion order, which is NoticedAt order, and it is +// deliberately not OccurredAt order: a cold feed read publishes a week of items +// in feed order, so sorting by when things happened would put a six-day-old +// item above one that arrived before it. Readers must label the column +// accordingly. func (b *Bus) Recent(limit int) []Event { if b == nil { return nil diff --git a/internal/event/event.go b/internal/event/event.go index add6420..64a8457 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -79,6 +79,18 @@ type Event struct { // the envelope must not flatten it. OccurredAt time.Time `json:"occurred_at"` + // NoticedAt — when Maven saw it. This is the ring's own ordering and the + // one the page sorts and labels by. + // + // It exists because OccurredAt must stay truthful and therefore cannot be + // an arrival order. A feed's first read returns twenty items spread over a + // week and publishes them in feed order; the ambient relay writes a 09:00 + // notification about an 18:00 meeting. Ordering the journal by OccurredAt + // while walking the ring backwards made the timestamp column run forwards + // and backwards on the same page. Normalize fills it from now, always: a + // caller does not get to say when Maven noticed something. + NoticedAt time.Time `json:"noticed_at"` + // Payload — source-specific extra, opaque here. Optional. Payload json.RawMessage `json:"payload,omitempty"` } @@ -143,6 +155,8 @@ func (e Event) Normalize(now time.Time) Event { if e.OccurredAt.IsZero() { e.OccurredAt = now } + // Notice time is not a caller's to set: it is the instant the bus took it. + e.NoticedAt = now return e } @@ -184,13 +198,14 @@ func truncateRunes(s string, n int) string { // a switch per writer: the source prefix already tells you what arrived. // // Unknown prefixes get fallback, which is what the caller was going to write -// anyway (a WriteFact call knows it is a fact). +// anyway (a WriteFact call knows it is a fact). There is deliberately no +// "email:" rule: the mail path constructs its task envelope itself, so mapping +// the prefix here would have journalled any future fact or note written under +// an email source as a captured task. func SourceKind(source, fallback string) string { switch { case strings.HasPrefix(source, "rss:"), strings.HasPrefix(source, "crawl:"): return KindNote - case strings.HasPrefix(source, "email:"): - return KindTask case strings.HasPrefix(source, "probe:"), strings.HasPrefix(source, "health:"): return KindHealth } diff --git a/internal/event/event_test.go b/internal/event/event_test.go index 0eaec48..27bffa6 100644 --- a/internal/event/event_test.go +++ b/internal/event/event_test.go @@ -78,9 +78,11 @@ func TestValid(t *testing.T) { func TestSourceKind(t *testing.T) { cases := map[string]string{ - "rss:tech": KindNote, - "crawl:kernel": KindNote, - "email:inbox": KindTask, + "rss:tech": KindNote, + "crawl:kernel": KindNote, + // No email rule: a WriteFact under an email source is a fact, not a + // captured task. The mail path builds its own task envelope. + "email:inbox": KindFact, "probe:netdata": KindHealth, "ambient:notif": KindFact, "tap:voice": KindFact, @@ -183,3 +185,41 @@ func TestBusConcurrentPublish(t *testing.T) { t.Errorf("Len = %d, want 160", b.Len()) } } + +// The ring is insertion-ordered and the page calls itself newest first, so the +// two only agree if the column is notice time. A cold feed read publishes a +// week of items in feed order, which used to make the OccurredAt column walk +// forwards and backwards on the same page. +func TestRecentIsOrderedByNoticeTimeNotByWhenThingsHappened(t *testing.T) { + b := NewBus(8) + // Published in feed order, six days old first, and a mark stamped now. + for i, e := range []Event{ + {Source: "rss:t", Kind: KindNote, Title: "six days ago", OccurredAt: testNow.Add(-6 * 24 * time.Hour)}, + {Source: "rss:t", Kind: KindNote, Title: "two days ago", OccurredAt: testNow.Add(-2 * 24 * time.Hour)}, + {Source: "ambient:notif", Kind: KindFact, Title: "a meeting at six", OccurredAt: testNow.Add(9 * time.Hour)}, + } { + b.Publish(e, testNow.Add(time.Duration(i)*time.Second)) + } + got := b.Recent(0) + want := []string{"a meeting at six", "two days ago", "six days ago"} + for i, w := range want { + if got[i].Title != w { + t.Errorf("Recent()[%d] = %q, want %q (notice order)", i, got[i].Title, w) + } + } + // Notice time is monotone down the page even where occurrence time is not. + for i := 1; i < len(got); i++ { + if got[i].NoticedAt.After(got[i-1].NoticedAt) { + t.Errorf("NoticedAt is not descending at %d", i) + } + } + if got[0].OccurredAt.Before(got[1].OccurredAt) { + t.Fatal("this fixture is supposed to have occurrence time out of order") + } + // A caller does not get to claim when Maven noticed something. + forged := Event{Source: "s", Kind: KindFact, Title: "t", NoticedAt: testNow.Add(100 * time.Hour)} + b.Publish(forged, testNow) + if n := b.Recent(1)[0].NoticedAt; !n.Equal(testNow) { + t.Errorf("NoticedAt = %v, want the publish instant %v", n, testNow) + } +} diff --git a/internal/ipc/api.go b/internal/ipc/api.go index 30cc4ab..b8863fe 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -685,6 +685,10 @@ type IntakeEvent struct { Body string `json:"body,omitempty"` Priority string `json:"priority"` OccurredAt time.Time `json:"occurred_at"` + // NoticedAt — when the journal took it. This is the order the ring returns + // and the one a page must sort and label by; OccurredAt is when the thing + // happened, which for a cold feed read is a week before it arrived. + NoticedAt time.Time `json:"noticed_at"` } // --- Rule trace / explanation DTOs ---