feat(daemon): add notification center ring and pending queue
Adds noticeCenter: a 200-entry history ring and a 32-cap pending-global queue (deduped by NoticeCode) for daemon-wide user notifications. Store only — Task 3 wires reportError/notify and client draining on top.
- commit
- dbdedb471aea6ee434e6d69538c9c27342b5e375
- author
- brice · · 2026-07-20 12:06 UTC
- parent
- 004c25c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +137 -0
A internal/usecase/daemon/notify.go +82 -0
| @@ -0,0 +1,82 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | const ( | |
| 10 | + | noticeHistoryCap = 200 | |
| 11 | + | noticePendingCap = 32 | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // noticeCenter owns the daemon-wide notification history and the queue of | |
| 15 | + | // global notices awaiting a first attached client. Its mu is leaf-level: no | |
| 16 | + | // other lock is ever taken while holding it. | |
| 17 | + | type noticeCenter struct { | |
| 18 | + | mu sync.Mutex | |
| 19 | + | ring []domain.Notification | |
| 20 | + | pending []domain.Notification | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func newNoticeCenter() *noticeCenter { return ¬iceCenter{} } | |
| 24 | + | ||
| 25 | + | func (nc *noticeCenter) record(n domain.Notification) domain.Notification { | |
| 26 | + | if n.Count == 0 { | |
| 27 | + | n.Count = 1 | |
| 28 | + | } | |
| 29 | + | nc.mu.Lock() | |
| 30 | + | defer nc.mu.Unlock() | |
| 31 | + | nc.ring = append(nc.ring, n) | |
| 32 | + | if len(nc.ring) > noticeHistoryCap { | |
| 33 | + | nc.ring = nc.ring[len(nc.ring)-noticeHistoryCap:] | |
| 34 | + | } | |
| 35 | + | return n | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (nc *noticeCenter) history() []domain.Notification { | |
| 39 | + | nc.mu.Lock() | |
| 40 | + | defer nc.mu.Unlock() | |
| 41 | + | out := make([]domain.Notification, len(nc.ring)) | |
| 42 | + | for i, n := range nc.ring { | |
| 43 | + | out[len(nc.ring)-1-i] = n | |
| 44 | + | } | |
| 45 | + | return out | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (nc *noticeCenter) latest() (domain.Notification, bool) { | |
| 49 | + | nc.mu.Lock() | |
| 50 | + | defer nc.mu.Unlock() | |
| 51 | + | if len(nc.ring) == 0 { | |
| 52 | + | return domain.Notification{}, false | |
| 53 | + | } | |
| 54 | + | return nc.ring[len(nc.ring)-1], true | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (nc *noticeCenter) queueGlobal(n domain.Notification) { | |
| 58 | + | if n.Count == 0 { | |
| 59 | + | n.Count = 1 | |
| 60 | + | } | |
| 61 | + | nc.mu.Lock() | |
| 62 | + | defer nc.mu.Unlock() | |
| 63 | + | for i := range nc.pending { | |
| 64 | + | if nc.pending[i].Code == n.Code { | |
| 65 | + | nc.pending[i].Count += n.Count | |
| 66 | + | nc.pending[i].Time = n.Time | |
| 67 | + | return | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if len(nc.pending) >= noticePendingCap { | |
| 71 | + | return // history already has it via record(); toast is dropped | |
| 72 | + | } | |
| 73 | + | nc.pending = append(nc.pending, n) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (nc *noticeCenter) drainPending() []domain.Notification { | |
| 77 | + | nc.mu.Lock() | |
| 78 | + | defer nc.mu.Unlock() | |
| 79 | + | out := nc.pending | |
| 80 | + | nc.pending = nil | |
| 81 | + | return out | |
| 82 | + | } | |
| @@ -0,0 +1,82 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "sync" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | const ( | |||
| 10 | + | noticeHistoryCap = 200 | |||
| 11 | + | noticePendingCap = 32 | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // noticeCenter owns the daemon-wide notification history and the queue of | |||
| 15 | + | // global notices awaiting a first attached client. Its mu is leaf-level: no | |||
| 16 | + | // other lock is ever taken while holding it. | |||
| 17 | + | type noticeCenter struct { | |||
| 18 | + | mu sync.Mutex | |||
| 19 | + | ring []domain.Notification | |||
| 20 | + | pending []domain.Notification | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func newNoticeCenter() *noticeCenter { return ¬iceCenter{} } | |||
| 24 | + | ||||
| 25 | + | func (nc *noticeCenter) record(n domain.Notification) domain.Notification { | |||
| 26 | + | if n.Count == 0 { | |||
| 27 | + | n.Count = 1 | |||
| 28 | + | } | |||
| 29 | + | nc.mu.Lock() | |||
| 30 | + | defer nc.mu.Unlock() | |||
| 31 | + | nc.ring = append(nc.ring, n) | |||
| 32 | + | if len(nc.ring) > noticeHistoryCap { | |||
| 33 | + | nc.ring = nc.ring[len(nc.ring)-noticeHistoryCap:] | |||
| 34 | + | } | |||
| 35 | + | return n | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (nc *noticeCenter) history() []domain.Notification { | |||
| 39 | + | nc.mu.Lock() | |||
| 40 | + | defer nc.mu.Unlock() | |||
| 41 | + | out := make([]domain.Notification, len(nc.ring)) | |||
| 42 | + | for i, n := range nc.ring { | |||
| 43 | + | out[len(nc.ring)-1-i] = n | |||
| 44 | + | } | |||
| 45 | + | return out | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (nc *noticeCenter) latest() (domain.Notification, bool) { | |||
| 49 | + | nc.mu.Lock() | |||
| 50 | + | defer nc.mu.Unlock() | |||
| 51 | + | if len(nc.ring) == 0 { | |||
| 52 | + | return domain.Notification{}, false | |||
| 53 | + | } | |||
| 54 | + | return nc.ring[len(nc.ring)-1], true | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func (nc *noticeCenter) queueGlobal(n domain.Notification) { | |||
| 58 | + | if n.Count == 0 { | |||
| 59 | + | n.Count = 1 | |||
| 60 | + | } | |||
| 61 | + | nc.mu.Lock() | |||
| 62 | + | defer nc.mu.Unlock() | |||
| 63 | + | for i := range nc.pending { | |||
| 64 | + | if nc.pending[i].Code == n.Code { | |||
| 65 | + | nc.pending[i].Count += n.Count | |||
| 66 | + | nc.pending[i].Time = n.Time | |||
| 67 | + | return | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | if len(nc.pending) >= noticePendingCap { | |||
| 71 | + | return // history already has it via record(); toast is dropped | |||
| 72 | + | } | |||
| 73 | + | nc.pending = append(nc.pending, n) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (nc *noticeCenter) drainPending() []domain.Notification { | |||
| 77 | + | nc.mu.Lock() | |||
| 78 | + | defer nc.mu.Unlock() | |||
| 79 | + | out := nc.pending | |||
| 80 | + | nc.pending = nil | |||
| 81 | + | return out | |||
| 82 | + | } | |||
A internal/usecase/daemon/notify_test.go +55 -0
| @@ -0,0 +1,55 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification { | |
| 11 | + | return domain.Notification{Code: code, Severity: domain.NoticeError, Message: msg, Time: time.Unix(1, 0), SessionID: sid} | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | func TestNoticeCenterRingEviction(t *testing.T) { | |
| 15 | + | nc := newNoticeCenter() | |
| 16 | + | for i := 0; i < 205; i++ { | |
| 17 | + | nc.record(notice(domain.NoticeInternal, "m", "")) | |
| 18 | + | } | |
| 19 | + | h := nc.history() | |
| 20 | + | if len(h) != 200 { | |
| 21 | + | t.Fatalf("history len = %d, want 200", len(h)) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestNoticeCenterHistoryNewestFirst(t *testing.T) { | |
| 26 | + | nc := newNoticeCenter() | |
| 27 | + | nc.record(notice(domain.NoticePaneSpawn, "first", "")) | |
| 28 | + | nc.record(notice(domain.NoticeTabSpawn, "second", "")) | |
| 29 | + | h := nc.history() | |
| 30 | + | if h[0].Message != "second" || h[1].Message != "first" { | |
| 31 | + | t.Fatalf("history order = %q,%q; want second,first", h[0].Message, h[1].Message) | |
| 32 | + | } | |
| 33 | + | last, ok := nc.latest() | |
| 34 | + | if !ok || last.Message != "second" { | |
| 35 | + | t.Fatalf("latest = %v,%v", last.Message, ok) | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestNoticeCenterPendingQueue(t *testing.T) { | |
| 40 | + | nc := newNoticeCenter() | |
| 41 | + | for i := 0; i < 40; i++ { | |
| 42 | + | nc.queueGlobal(notice(domain.NoticeSnapshotRestore, "restore failed", "")) | |
| 43 | + | } | |
| 44 | + | nc.queueGlobal(notice(domain.NoticePersistDisabled, "persistence disabled", "")) | |
| 45 | + | pending := nc.drainPending() | |
| 46 | + | if len(pending) != 2 { | |
| 47 | + | t.Fatalf("pending len = %d, want 2 (deduped by code)", len(pending)) | |
| 48 | + | } | |
| 49 | + | if pending[0].Count != 40 { | |
| 50 | + | t.Fatalf("coalesced Count = %d, want 40", pending[0].Count) | |
| 51 | + | } | |
| 52 | + | if got := nc.drainPending(); len(got) != 0 { | |
| 53 | + | t.Fatalf("second drain len = %d, want 0", len(got)) | |
| 54 | + | } | |
| 55 | + | } | |
| @@ -0,0 +1,55 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification { | |||
| 11 | + | return domain.Notification{Code: code, Severity: domain.NoticeError, Message: msg, Time: time.Unix(1, 0), SessionID: sid} | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | func TestNoticeCenterRingEviction(t *testing.T) { | |||
| 15 | + | nc := newNoticeCenter() | |||
| 16 | + | for i := 0; i < 205; i++ { | |||
| 17 | + | nc.record(notice(domain.NoticeInternal, "m", "")) | |||
| 18 | + | } | |||
| 19 | + | h := nc.history() | |||
| 20 | + | if len(h) != 200 { | |||
| 21 | + | t.Fatalf("history len = %d, want 200", len(h)) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestNoticeCenterHistoryNewestFirst(t *testing.T) { | |||
| 26 | + | nc := newNoticeCenter() | |||
| 27 | + | nc.record(notice(domain.NoticePaneSpawn, "first", "")) | |||
| 28 | + | nc.record(notice(domain.NoticeTabSpawn, "second", "")) | |||
| 29 | + | h := nc.history() | |||
| 30 | + | if h[0].Message != "second" || h[1].Message != "first" { | |||
| 31 | + | t.Fatalf("history order = %q,%q; want second,first", h[0].Message, h[1].Message) | |||
| 32 | + | } | |||
| 33 | + | last, ok := nc.latest() | |||
| 34 | + | if !ok || last.Message != "second" { | |||
| 35 | + | t.Fatalf("latest = %v,%v", last.Message, ok) | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestNoticeCenterPendingQueue(t *testing.T) { | |||
| 40 | + | nc := newNoticeCenter() | |||
| 41 | + | for i := 0; i < 40; i++ { | |||
| 42 | + | nc.queueGlobal(notice(domain.NoticeSnapshotRestore, "restore failed", "")) | |||
| 43 | + | } | |||
| 44 | + | nc.queueGlobal(notice(domain.NoticePersistDisabled, "persistence disabled", "")) | |||
| 45 | + | pending := nc.drainPending() | |||
| 46 | + | if len(pending) != 2 { | |||
| 47 | + | t.Fatalf("pending len = %d, want 2 (deduped by code)", len(pending)) | |||
| 48 | + | } | |||
| 49 | + | if pending[0].Count != 40 { | |||
| 50 | + | t.Fatalf("coalesced Count = %d, want 40", pending[0].Count) | |||
| 51 | + | } | |||
| 52 | + | if got := nc.drainPending(); len(got) != 0 { | |||
| 53 | + | t.Fatalf("second drain len = %d, want 0", len(got)) | |||
| 54 | + | } | |||
| 55 | + | } | |||