Skip to content

Commit 2f9709d

Browse files
committed
feat(dashboard/contract): slice (k) audit storage + live audit.tail subscription
Closes two related gaps: 1. The audit emitter (slice b) wrote to stdout/Logger and disappeared. There was no way for the dashboard to surface audited commands. 2. The audit.tail vocabulary intent + React component (slice e) had no matching server-side handler — clicking the audit widget produced nothing. Added: - contract/audit_store.go: - AuditStore interface + AuditFilter (Limit/Contributor/Intent/User/Result). - In-memory ring-buffer impl (default cap 1000), fan-out subscriptions, non-blocking sends so a slow consumer can't block command writes. - RecordingAuditEmitter that wraps an inner emitter (existing log-based one) and persists to the store. - contract/pilot/audit.go: - AuditProvider interface + AuditRecordDTO with RFC3339Nano timestamps. - audit.list query handler (filters + nil-store -> CodeUnavailable). - audit.tail subscription handler (streams Append events; cancellation tears down the subscriber cleanly). - pilot/manifest.yaml: - Two new intents (audit.list query, audit.tail subscription/append). - auditList named query with 5s staleTime cache. - New /audit route under Operations nav rendering audit.tail. - contract/slots.go: extend page.shell.main Accepts to include audit.tail so the new top-level route validates. - extension.go: - Construct e.auditStore at NewExtension time. - Wrap the chosen audit emitter (log/structured) with RecordingAuditEmitter so commands flow into both log lines AND the store. - Pass e.auditStore as pilot.Deps.Audit. Tests: - audit_store_test.go: append/list ordering, intent filter, limit clamping, ring truncation, subscribe broadcast, cancel-closes-channel, RecordingEmitter fan-out. - pilot/audit_test.go: handler projection (timestamp formatting), CodeUnavailable on nil provider, subscription streams Appends, CodeUnavailable from subscription handler. - pilot/types_test.go: bumped manifest counts (intents 9->11, routes 8->9). go build + go test ./... clean (37 packages green).
1 parent 0f02eec commit 2f9709d

10 files changed

Lines changed: 651 additions & 10 deletions

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# Slice (k) — Audit storage + live `audit.tail` subscription
2+
3+
**Status:** Active
4+
**Branch:** `dashboard-contract-slice-a`
5+
**Predecessors:** (a)–(j)
6+
7+
## Why
8+
9+
The audit emitter (slice b) writes to stdout/Logger via `LogAuditEmitter` and disappears. The vocabulary intent `audit.tail` (slice e) has had a React component that subscribes to a server intent named `audit.tail`, but that intent was never registered — clicking the audit widget would silently produce nothing. Slice (k) closes both gaps:
10+
11+
1. **Persistent (in-memory) audit storage** — every command run is stored in a ring buffer the dashboard can query.
12+
2. **`audit.list` query intent** — paginated history of recent commands.
13+
3. **`audit.tail` subscription intent** — live append-mode stream of new audit records, which is what the React `AuditTail` component is already wired to consume.
14+
4. **`/audit` route** — top-level page in the manifest that combines history + live tail.
15+
16+
This unlocks the audit widget that's been a no-op since slice (e) and gives the dashboard real command observability without external infrastructure.
17+
18+
## Approach
19+
20+
### 1. `AuditStore` interface + in-memory impl
21+
22+
```go
23+
type AuditStore interface {
24+
Append(rec AuditRecord)
25+
List(filter AuditFilter) []AuditRecord // newest first, capped by Limit
26+
Subscribe() (<-chan AuditRecord, func())
27+
}
28+
```
29+
30+
In-memory implementation:
31+
- Ring buffer (default cap 1000) protected by RWMutex.
32+
- `List(filter)` walks newest→oldest, applying optional `User`, `Contributor`, `Intent`, `Result` filters; returns up to `filter.Limit` (default 200, max 1000).
33+
- `Subscribe()` registers a fan-out channel; `Append()` non-blocking-sends to all subscribers (drops on slow consumers — telemetry, not authoritative).
34+
35+
The store lives in `extensions/dashboard/contract` next to `audit.go`.
36+
37+
### 2. `RecordingAuditEmitter`
38+
39+
Wraps any inner `AuditEmitter` and adds a store side-effect:
40+
41+
```go
42+
func NewRecordingAuditEmitter(inner AuditEmitter, store AuditStore) AuditEmitter
43+
```
44+
45+
Slice (b) wired `dispatcher.NewLoggerAuditEmitter(...)` into the extension. After slice (k) the wiring becomes `NewRecordingAuditEmitter(NewLoggerAuditEmitter(...), e.auditStore)` so log-line semantics stay and the store fills.
46+
47+
### 3. `audit.list` query handler
48+
49+
Lives in pilot at `extensions/dashboard/contract/pilot/audit.go`:
50+
51+
```go
52+
type AuditListInput struct {
53+
Limit int `json:"limit,omitempty"`
54+
Contributor string `json:"contributor,omitempty"`
55+
Intent string `json:"intent,omitempty"`
56+
User string `json:"user,omitempty"`
57+
Result string `json:"result,omitempty"`
58+
}
59+
type AuditListResponse struct {
60+
Records []AuditRecordDTO `json:"records"`
61+
Total int `json:"total"`
62+
}
63+
```
64+
65+
Records are projected to a wire-friendly DTO with RFC3339Nano timestamps and the same fields the React component renders.
66+
67+
### 4. `audit.tail` subscription handler
68+
69+
A `dispatcher.SubscriptionHandler` that calls `store.Subscribe()`, fans events out as `StreamEvent{Mode: ModeAppend, Payload: AuditRecordDTO}`. Cancellation closes the subscriber channel.
70+
71+
### 5. Manifest entry
72+
73+
Add to `pilot/manifest.yaml`:
74+
75+
```yaml
76+
intents:
77+
- { name: audit.list, kind: query, version: 1, capability: read }
78+
- { name: audit.tail, kind: subscription, version: 1, capability: read, mode: append }
79+
80+
queries:
81+
auditList:
82+
intent: audit.list
83+
cache: { staleTime: 5s }
84+
85+
graph:
86+
- route: /audit
87+
intent: page.shell
88+
title: Audit
89+
nav: { group: Operations, icon: history, priority: 23 }
90+
slots:
91+
main:
92+
- intent: audit.tail
93+
title: Live audit
94+
data:
95+
intent: audit.tail
96+
props:
97+
bufferSize: 200
98+
```
99+
100+
This is the first time `audit.tail` appears in `page.shell.main`. Slot vocabulary needs an update: today `page.shell.main` accepts `[resource.list, resource.detail, dashboard.grid, form.edit, custom, iframe]` — extend with `audit.tail`.
101+
102+
The `audit.list` query stays available for future history-style pages; v1 of the audit page only ships the live tail because the React `AuditTail` component is already built around subscriptions and history-styled rendering would need a new component.
103+
104+
### 6. Wiring in `extension.go`
105+
106+
- `NewExtension` constructs `e.auditStore = contract.NewInMemoryAuditStore()`
107+
- `e.auditEmitter = contract.NewRecordingAuditEmitter(<existing emitter>, e.auditStore)` after the existing `auditEmitter` selection block
108+
- Pilot `Deps` gets a new `Audit AuditProvider` field carrying the store; `Register()` registers the two handlers when non-nil
109+
110+
## Tests
111+
112+
- **Store:** `TestAuditStore_AppendList` (round-trip), `TestAuditStore_Filter`, `TestAuditStore_RingTruncates`, `TestAuditStore_SubscribeBroadcasts`, `TestAuditStore_DropsSlowSubscriber`.
113+
- **RecordingEmitter:** chains to inner + writes to store.
114+
- **Pilot audit.list handler:** happy path, projection (timestamps formatted), CodeUnavailable when nil store, filter behavior.
115+
- **Pilot audit.tail handler:** subscribes, emits events when store appends, cancellation cleans up.
116+
- **Manifest:** loads with new intents, validates with new vocabulary entry.
117+
118+
## Out of scope
119+
120+
- Persistent storage backends (Postgres, SQLite). The interface is shaped to allow them; in-memory is the slice-(k) impl.
121+
- Pagination cursors for `audit.list` (limit/offset would need a stable order key — slice (k2) when audit becomes a real history page).
122+
- Per-tenant scoping. The store is global; multi-tenant filtering happens in handlers when we add tenant resolution to AuditRecord (slice l, separate concern).
123+
- A history-rendering React component for `audit.list`. Slice (k) ships the live tail (`audit.tail`); a `resource.list`-style audit history page is a follow-on.
124+
125+
## Why not split
126+
127+
`audit.list` and `audit.tail` share the store and the projection helpers. The vocabulary update + manifest route benefits from being one PR with a single test pass. ~250 LOC total.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package contract
2+
3+
import (
4+
"context"
5+
"sync"
6+
)
7+
8+
// AuditStore is the persistent (process-local for slice (k)) view of audit
9+
// records. It exists to back the audit.list query and audit.tail subscription
10+
// the dashboard exposes; production deployments swap the in-memory impl for a
11+
// durable backend when one is wired.
12+
type AuditStore interface {
13+
Append(rec AuditRecord)
14+
List(filter AuditFilter) []AuditRecord
15+
// Subscribe returns a channel that receives every Append from now on, plus
16+
// a cancel func that closes the channel and unregisters the subscriber.
17+
// Slow subscribers drop events rather than block writers — audit is
18+
// telemetry, not the source of truth.
19+
Subscribe() (<-chan AuditRecord, func())
20+
}
21+
22+
// AuditFilter narrows audit.list results. All fields are optional. Limit is
23+
// clamped to [1, 1000]; zero defaults to 200.
24+
type AuditFilter struct {
25+
Limit int
26+
Contributor string
27+
Intent string
28+
User string
29+
Result string
30+
}
31+
32+
const (
33+
defaultAuditListLimit = 200
34+
maxAuditListLimit = 1000
35+
)
36+
37+
// NewInMemoryAuditStore returns a store that keeps the most recent `cap`
38+
// records in a ring buffer. cap <= 0 defaults to 1000.
39+
func NewInMemoryAuditStore(cap int) AuditStore {
40+
if cap <= 0 {
41+
cap = 1000
42+
}
43+
return &memAuditStore{
44+
buf: make([]AuditRecord, 0, cap),
45+
cap: cap,
46+
}
47+
}
48+
49+
type memAuditStore struct {
50+
mu sync.RWMutex
51+
buf []AuditRecord
52+
cap int
53+
subs []chan AuditRecord
54+
subSeq int // monotonic id for a future remove-by-id; not exposed yet
55+
subsCleanup []chan AuditRecord
56+
}
57+
58+
func (s *memAuditStore) Append(rec AuditRecord) {
59+
s.mu.Lock()
60+
if len(s.buf) >= s.cap {
61+
// drop oldest
62+
copy(s.buf, s.buf[1:])
63+
s.buf = s.buf[:len(s.buf)-1]
64+
}
65+
s.buf = append(s.buf, rec)
66+
subs := append([]chan AuditRecord(nil), s.subs...)
67+
s.mu.Unlock()
68+
// non-blocking fan-out — slow subscribers drop events
69+
for _, ch := range subs {
70+
select {
71+
case ch <- rec:
72+
default:
73+
}
74+
}
75+
}
76+
77+
func (s *memAuditStore) List(filter AuditFilter) []AuditRecord {
78+
limit := filter.Limit
79+
if limit <= 0 {
80+
limit = defaultAuditListLimit
81+
}
82+
if limit > maxAuditListLimit {
83+
limit = maxAuditListLimit
84+
}
85+
s.mu.RLock()
86+
defer s.mu.RUnlock()
87+
out := make([]AuditRecord, 0, limit)
88+
// walk newest -> oldest
89+
for i := len(s.buf) - 1; i >= 0 && len(out) < limit; i-- {
90+
rec := s.buf[i]
91+
if filter.Contributor != "" && rec.Contributor != filter.Contributor {
92+
continue
93+
}
94+
if filter.Intent != "" && rec.Intent != filter.Intent {
95+
continue
96+
}
97+
if filter.User != "" && rec.User != filter.User {
98+
continue
99+
}
100+
if filter.Result != "" && rec.Result != filter.Result {
101+
continue
102+
}
103+
out = append(out, rec)
104+
}
105+
return out
106+
}
107+
108+
func (s *memAuditStore) Subscribe() (<-chan AuditRecord, func()) {
109+
ch := make(chan AuditRecord, 32)
110+
s.mu.Lock()
111+
s.subs = append(s.subs, ch)
112+
s.mu.Unlock()
113+
cancel := func() {
114+
s.mu.Lock()
115+
for i, c := range s.subs {
116+
if c == ch {
117+
s.subs = append(s.subs[:i], s.subs[i+1:]...)
118+
break
119+
}
120+
}
121+
s.mu.Unlock()
122+
close(ch)
123+
}
124+
return ch, cancel
125+
}
126+
127+
// NewRecordingAuditEmitter returns an emitter that fans out to inner (typically
128+
// the log emitter) and also persists to store. Either may be nil; both nil is
129+
// a noop.
130+
func NewRecordingAuditEmitter(inner AuditEmitter, store AuditStore) AuditEmitter {
131+
return &recordingEmitter{inner: inner, store: store}
132+
}
133+
134+
type recordingEmitter struct {
135+
inner AuditEmitter
136+
store AuditStore
137+
}
138+
139+
func (e *recordingEmitter) Emit(ctx context.Context, rec AuditRecord) {
140+
if e.store != nil {
141+
e.store.Append(rec)
142+
}
143+
if e.inner != nil {
144+
e.inner.Emit(ctx, rec)
145+
}
146+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package contract
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
)
8+
9+
func mkRec(intent string, t time.Time) AuditRecord {
10+
return AuditRecord{
11+
Time: t, Contributor: "core-contract", Intent: intent, Result: "ok",
12+
}
13+
}
14+
15+
func TestAuditStore_AppendList(t *testing.T) {
16+
s := NewInMemoryAuditStore(0)
17+
now := time.Now()
18+
s.Append(mkRec("a", now.Add(-1*time.Second)))
19+
s.Append(mkRec("b", now))
20+
got := s.List(AuditFilter{})
21+
if len(got) != 2 {
22+
t.Fatalf("len = %d", len(got))
23+
}
24+
if got[0].Intent != "b" || got[1].Intent != "a" {
25+
t.Errorf("expected newest-first ordering, got %+v", got)
26+
}
27+
}
28+
29+
func TestAuditStore_FilterIntent(t *testing.T) {
30+
s := NewInMemoryAuditStore(0)
31+
s.Append(mkRec("a", time.Now()))
32+
s.Append(mkRec("b", time.Now()))
33+
s.Append(mkRec("a", time.Now()))
34+
got := s.List(AuditFilter{Intent: "a"})
35+
if len(got) != 2 {
36+
t.Errorf("expected 2 intent=a records, got %d", len(got))
37+
}
38+
}
39+
40+
func TestAuditStore_LimitClamping(t *testing.T) {
41+
s := NewInMemoryAuditStore(0)
42+
for i := 0; i < 50; i++ {
43+
s.Append(mkRec("x", time.Now()))
44+
}
45+
if got := s.List(AuditFilter{Limit: 5}); len(got) != 5 {
46+
t.Errorf("limit=5 -> %d records", len(got))
47+
}
48+
if got := s.List(AuditFilter{Limit: -1}); len(got) != 50 {
49+
t.Errorf("limit=-1 -> %d records (want default 50 since cap < default)", len(got))
50+
}
51+
}
52+
53+
func TestAuditStore_RingTruncates(t *testing.T) {
54+
s := NewInMemoryAuditStore(3)
55+
for i := 0; i < 5; i++ {
56+
s.Append(AuditRecord{Time: time.Now(), Intent: "x", User: string(rune('a' + i))})
57+
}
58+
got := s.List(AuditFilter{})
59+
if len(got) != 3 {
60+
t.Fatalf("expected 3 (cap), got %d", len(got))
61+
}
62+
// Newest is "e", oldest kept is "c"; "a" and "b" dropped.
63+
if got[0].User != "e" || got[2].User != "c" {
64+
t.Errorf("ring kept wrong records: %+v", got)
65+
}
66+
}
67+
68+
func TestAuditStore_SubscribeBroadcasts(t *testing.T) {
69+
s := NewInMemoryAuditStore(0)
70+
ch, cancel := s.Subscribe()
71+
defer cancel()
72+
go s.Append(mkRec("hello", time.Now()))
73+
select {
74+
case rec := <-ch:
75+
if rec.Intent != "hello" {
76+
t.Errorf("got %+v", rec)
77+
}
78+
case <-time.After(time.Second):
79+
t.Fatal("no event received")
80+
}
81+
}
82+
83+
func TestAuditStore_CancelClosesChannel(t *testing.T) {
84+
s := NewInMemoryAuditStore(0)
85+
ch, cancel := s.Subscribe()
86+
cancel()
87+
select {
88+
case _, ok := <-ch:
89+
if ok {
90+
t.Errorf("expected closed channel")
91+
}
92+
case <-time.After(time.Second):
93+
t.Fatal("channel did not close")
94+
}
95+
}
96+
97+
func TestRecordingAuditEmitter_FansOut(t *testing.T) {
98+
store := NewInMemoryAuditStore(0)
99+
captured := []AuditRecord{}
100+
innerCalled := false
101+
inner := auditEmitterFunc(func(_ context.Context, rec AuditRecord) {
102+
innerCalled = true
103+
captured = append(captured, rec)
104+
})
105+
em := NewRecordingAuditEmitter(inner, store)
106+
em.Emit(context.Background(), mkRec("x", time.Now()))
107+
if !innerCalled {
108+
t.Errorf("inner emitter not called")
109+
}
110+
if len(captured) != 1 {
111+
t.Errorf("inner saw %d records", len(captured))
112+
}
113+
if got := store.List(AuditFilter{}); len(got) != 1 {
114+
t.Errorf("store has %d records", len(got))
115+
}
116+
}
117+
118+
// auditEmitterFunc is a tiny test-only adapter from func to AuditEmitter.
119+
type auditEmitterFunc func(ctx context.Context, rec AuditRecord)
120+
121+
func (f auditEmitterFunc) Emit(ctx context.Context, rec AuditRecord) { f(ctx, rec) }

0 commit comments

Comments
 (0)