Skip to content
This repository was archived by the owner on Sep 3, 2026. It is now read-only.

Commit 9c02dc6

Browse files
author
outlndrr
committed
feat(event-log): persist domain events
1 parent 1fee664 commit 9c02dc6

12 files changed

Lines changed: 347 additions & 14 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
- added CommandBus idempotency ack persistence with a 24-hour duplicate window.
3636
- added opt-in `LUMMY_COMMAND_BUS_ENABLED` server flag for HTTP write routes.
3737
- widened the internal EventBus payload to shared domain-event envelopes.
38+
- added an internal domain event log writer subscribed to EventBus.
3839

3940
### Added
4041
- Gleam project bootstrap for `lummy_agent`
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Review — Step 5.2 EventLog writer
2+
3+
Scope: `EventBus.subscribe_all`, `eventlog/event_log.gleam`, `domain_event_log` migration/repository, app supervisor wiring, tests.
4+
5+
## Findings
6+
7+
- No blocking correctness issues found in the landed slice.
8+
- Existing legacy `event_log` table is still used for session replay/SSE backlog, so the new append-only domain event table is named `domain_event_log`. This avoids destructive rename work in this phase.
9+
- `domain_event_log` uses `(aggregate_id, sequence)` primary key and `INSERT OR IGNORE`, so duplicate publish/retry does not double-write.
10+
- EventLog writer subscribes with `subscribe_all_named`; existing session-specific subscribers still receive only their aggregate events.
11+
- Writer is supervised after EventBus in app supervisor.
12+
13+
## Follow-up
14+
15+
- Writer message type is the EventBus subscription message, so it has no explicit `Shutdown` call surface. Acceptable for supervised internal subscriber now; revisit if tests need direct stop semantics.
16+
- Bounded mailbox/backpressure logging is still deferred per task note.
17+
- `domain_event_log` currently exposes count/append only. Replay/query APIs belong with later projection/backfill phases.
18+
19+
## Validation
20+
21+
- `gleam format --check && gleam check` — pass
22+
- `bash scripts/with-zig-env.sh gleam test` — pass, 188 tests
23+
- `cd shared && gleam format --check && gleam check && gleam test` — pass, 11 tests
24+
- `cd ui && gleam format --check && gleam check && gleam test` — pass, 13 tests
25+
- guard grep for `should.equal`, `io.debug`, `todo` — pass

docs/refactors/0004-tasks.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -251,13 +251,17 @@ Migrate pure domain types out of `src/` into `shared/`. Use re-export shims to k
251251

252252
### 5.2 EventLog table + writer actor
253253

254-
- [ ] Migration: `event_log` table (see plan §8 schema).
255-
- [ ] Migration: index on `(aggregate_id, sequence)` PK + `occurred_at`.
256-
- [ ] New module `agent/eventlog/event_log.gleam`:
257-
- [ ] Subscribes to EventBus on init.
258-
- [ ] On received envelope, encodes payload via codec from §3.3, appends to table.
254+
- [x] Migration: `event_log` table (see plan §8 schema).
255+
- Implemented as additive `domain_event_log` because legacy session replay already owns `event_log`.
256+
- [x] Migration: index on `(aggregate_id, sequence)` PK + `occurred_at`.
257+
- `(aggregate_id, sequence)` primary key plus `domain_event_log_occurred_at_idx`.
258+
- [x] New module `agent/eventlog/event_log.gleam`:
259+
- [x] Subscribes to EventBus on init.
260+
- EventBus now has `subscribe_all_named` for event-log fan-out.
261+
- [x] On received envelope, encodes payload via codec from §3.3, appends to table.
259262
- [ ] Bounded mailbox: above water mark, log warning, drop projection writes (NEVER drop event_log writes).
260-
- [ ] Supervised under app supervisor, after StorageOwner and EventBus.
263+
- Deferred: writer is one DB insert per event; add mailbox telemetry before projections fan out.
264+
- [x] Supervised under app supervisor, after StorageOwner and EventBus.
261265

262266
### 5.3 Backfill historical events
263267

@@ -504,7 +508,7 @@ For each major step, fill in:
504508
| 4.6 | | 2026-04-29 | | Added enabled-vs-direct HTTP write route parity coverage. |
505509
| 4.7 | | | | |
506510
| 5.1 | | 2026-04-29 | | EventBus widened to shared DomainEvent envelopes; SSE ignores non-session events. |
507-
| 5.2 | | | | |
511+
| 5.2 | | 2026-04-29 | | Added domain event log table/repository and EventLog writer subscriber. |
508512
| 5.3 | | | | |
509513
| 5.4 | | | | |
510514
| 5.5 | | | | |

src/lummy_agent/app/supervisor.gleam

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import gleam/erlang/process
22
import gleam/otp/actor
33
import gleam/otp/static_supervisor as supervisor
44
import lummy_agent/app/config as app_config
5+
import lummy_agent/eventlog/event_log
56
import lummy_agent/observability/metrics
67
import lummy_agent/observability/otel
78
import lummy_agent/run/run_supervisor
@@ -44,6 +45,11 @@ pub fn start(
4445
session_manager_name: session_manager_name,
4546
repositories: repositories,
4647
)
48+
let event_log_start_arg =
49+
event_log.StartArg(
50+
event_bus_name: event_bus_name,
51+
repositories: repositories,
52+
)
4753

4854
let http_dependencies =
4955
http_server.Dependencies(
@@ -59,6 +65,7 @@ pub fn start(
5965
config.storage.database_path,
6066
))
6167
|> supervisor.add(event_bus.supervised(event_bus_name))
68+
|> supervisor.add(event_log.supervised(event_log_start_arg))
6269
|> supervisor.add(session_supervisor.supervised(session_supervisor_name))
6370
|> supervisor.add(run_supervisor.supervised(run_supervisor_name))
6471
|> supervisor.add(session_manager.supervised(
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import gleam/erlang/process
2+
import gleam/otp/actor
3+
import gleam/otp/supervision
4+
import lummy_agent/domain/error as domain_error
5+
import lummy_agent/runtime/event_bus
6+
import lummy_agent/storage/repository
7+
8+
pub type StartArg {
9+
StartArg(
10+
event_bus_name: event_bus.Name,
11+
repositories: repository.Repositories,
12+
)
13+
}
14+
15+
pub type Handle =
16+
process.Subject(Message)
17+
18+
pub type Message =
19+
event_bus.SubscriptionMessage
20+
21+
type State {
22+
State(repositories: repository.Repositories)
23+
}
24+
25+
pub fn start(start_arg: StartArg) -> actor.StartResult(process.Subject(Message)) {
26+
actor.new_with_initialiser(1000, fn(subject) {
27+
event_bus.subscribe_all_named(start_arg.event_bus_name, subject)
28+
Ok(
29+
actor.initialised(State(repositories: start_arg.repositories))
30+
|> actor.returning(subject),
31+
)
32+
})
33+
|> actor.on_message(handle_message)
34+
|> actor.start
35+
}
36+
37+
pub fn supervised(start_arg: StartArg) {
38+
supervision.worker(fn() { start(start_arg) })
39+
}
40+
41+
fn handle_message(state: State, message: Message) -> actor.Next(State, Message) {
42+
case message {
43+
event_bus.Published(envelope) -> {
44+
let _ = append_event(state.repositories.domain_event_log, envelope)
45+
actor.continue(state)
46+
}
47+
}
48+
}
49+
50+
fn append_event(
51+
repo: repository.DomainEventLogRepository,
52+
envelope,
53+
) -> Result(Nil, domain_error.DomainError) {
54+
repository.append_domain_event_log(repo, envelope)
55+
}

src/lummy_agent/runtime/event_bus.gleam

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ pub type Message {
1616
subscriber: process.Subject(SubscriptionMessage),
1717
reply_with: process.Subject(Nil),
1818
)
19+
SubscribeAll(
20+
subscriber: process.Subject(SubscriptionMessage),
21+
reply_with: process.Subject(Nil),
22+
)
1923
Unsubscribe(
2024
session_id: domain_id.SessionId,
2125
subscriber: process.Subject(SubscriptionMessage),
@@ -32,11 +36,14 @@ pub type Name =
3236
process.Name(Message)
3337

3438
type State {
35-
State(subscriptions: Dict(String, List(process.Subject(SubscriptionMessage))))
39+
State(
40+
subscriptions: Dict(String, List(process.Subject(SubscriptionMessage))),
41+
all_subscribers: List(process.Subject(SubscriptionMessage)),
42+
)
3643
}
3744

3845
pub fn start(name: Name) -> actor.StartResult(Handle) {
39-
actor.new(State(subscriptions: dict.new()))
46+
actor.new(State(subscriptions: dict.new(), all_subscribers: []))
4047
|> actor.named(name)
4148
|> actor.on_message(handle_message)
4249
|> actor.start
@@ -60,6 +67,15 @@ pub fn subscribe_named(
6067
})
6168
}
6269

70+
pub fn subscribe_all_named(
71+
name: Name,
72+
subscriber: process.Subject(SubscriptionMessage),
73+
) -> Nil {
74+
actor.call(named_handle(name), waiting: 5000, sending: fn(reply) {
75+
SubscribeAll(subscriber:, reply_with: reply)
76+
})
77+
}
78+
6379
pub fn unsubscribe_named(
6480
name: Name,
6581
session_id: domain_id.SessionId,
@@ -88,7 +104,20 @@ fn handle_message(state: State, message: Message) -> actor.Next(State, Message)
88104
}
89105
actor.send(reply_with, Nil)
90106
actor.continue(
91-
State(subscriptions: dict.insert(state.subscriptions, key, subscribers)),
107+
State(
108+
..state,
109+
subscriptions: dict.insert(state.subscriptions, key, subscribers),
110+
),
111+
)
112+
}
113+
114+
SubscribeAll(subscriber, reply_with) -> {
115+
actor.send(reply_with, Nil)
116+
actor.continue(
117+
State(
118+
..state,
119+
all_subscribers: ensure_subscriber(state.all_subscribers, subscriber),
120+
),
92121
)
93122
}
94123

@@ -100,7 +129,10 @@ fn handle_message(state: State, message: Message) -> actor.Next(State, Message)
100129
}
101130
actor.send(reply_with, Nil)
102131
actor.continue(
103-
State(subscriptions: dict.insert(state.subscriptions, key, subscribers)),
132+
State(
133+
..state,
134+
subscriptions: dict.insert(state.subscriptions, key, subscribers),
135+
),
104136
)
105137
}
106138

@@ -111,6 +143,7 @@ fn handle_message(state: State, message: Message) -> actor.Next(State, Message)
111143
Error(_) -> []
112144
}
113145
subscribers
146+
|> list.append(state.all_subscribers)
114147
|> list.each(fn(subscriber) {
115148
actor.send(subscriber, Published(envelope))
116149
})

src/lummy_agent/storage/migration.gleam

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,36 @@ pub fn all() -> List(Migration) {
99
Migration(version: 1, name: "0001_initial", sql: initial_sql()),
1010
Migration(version: 2, name: "0002_session_pinned_at", sql: pinned_at_sql()),
1111
Migration(version: 3, name: "0003_command_acks", sql: command_acks_sql()),
12+
Migration(
13+
version: 4,
14+
name: "0004_domain_event_log",
15+
sql: domain_event_log_sql(),
16+
),
1217
]
1318
}
1419

1520
pub fn latest_version() -> Int {
16-
3
21+
4
22+
}
23+
24+
fn domain_event_log_sql() -> String {
25+
string.join(
26+
[
27+
"CREATE TABLE IF NOT EXISTS domain_event_log (",
28+
" aggregate_id TEXT NOT NULL,",
29+
" sequence INTEGER NOT NULL,",
30+
" occurred_at INTEGER NOT NULL,",
31+
" causation_id TEXT,",
32+
" correlation_id TEXT,",
33+
" event_name TEXT NOT NULL,",
34+
" payload TEXT NOT NULL,",
35+
" PRIMARY KEY (aggregate_id, sequence)",
36+
");",
37+
"CREATE INDEX IF NOT EXISTS domain_event_log_occurred_at_idx",
38+
" ON domain_event_log(occurred_at);",
39+
],
40+
with: "\n",
41+
)
1742
}
1843

1944
fn command_acks_sql() -> String {

src/lummy_agent/storage/repository.gleam

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import lummy_agent/domain/run
66
import lummy_agent/domain/session
77
import lummy_agent/domain/tool_call
88
import lummy_agent/session/event as session_event
9+
import lummy_agent_shared/event/envelope as shared_envelope
910

1011
pub type SessionRepository {
1112
SessionRepository(
@@ -88,6 +89,13 @@ pub type CommandAckRepository {
8889
)
8990
}
9091

92+
pub type DomainEventLogRepository {
93+
DomainEventLogRepository(
94+
append: fn(shared_envelope.EventEnvelope) -> Result(Nil, error.DomainError),
95+
count: fn() -> Result(Int, error.DomainError),
96+
)
97+
}
98+
9199
pub type Repositories {
92100
Repositories(
93101
sessions: SessionRepository,
@@ -97,6 +105,7 @@ pub type Repositories {
97105
events: EventRepository,
98106
commits: CommitRepository,
99107
command_acks: CommandAckRepository,
108+
domain_event_log: DomainEventLogRepository,
100109
)
101110
}
102111

@@ -252,6 +261,19 @@ pub fn insert_command_ack(
252261
repo.insert(command_id, idempotency_key, accepted_at, accepted_at_epoch)
253262
}
254263

264+
pub fn append_domain_event_log(
265+
repo: DomainEventLogRepository,
266+
envelope: shared_envelope.EventEnvelope,
267+
) -> Result(Nil, error.DomainError) {
268+
repo.append(envelope)
269+
}
270+
271+
pub fn count_domain_event_log(
272+
repo: DomainEventLogRepository,
273+
) -> Result(Int, error.DomainError) {
274+
repo.count()
275+
}
276+
255277
pub fn list_events_for_session(
256278
repo: EventRepository,
257279
session_id: id.SessionId,

src/lummy_agent/storage/sqlite.gleam

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import lummy_agent/storage/migration
1616
import lummy_agent/storage/repository
1717
import lummy_agent/storage/sqlite_commit as commit_db
1818
import lummy_agent/storage/sqlite_connection.{type Connection}
19+
import lummy_agent/storage/sqlite_domain_event_log as domain_event_log_db
1920
import lummy_agent/storage/sqlite_event as event_db
2021
import lummy_agent/storage/sqlite_lowlevel as lowlevel
2122
import lummy_agent/storage/sqlite_message as message_db
@@ -96,6 +97,10 @@ pub type OwnerMessage {
9697
operation: fn(Connection) -> StorageResult(option.Option(String)),
9798
reply_with: process.Subject(StorageResult(option.Option(String))),
9899
)
100+
RunInt(
101+
operation: fn(Connection) -> StorageResult(Int),
102+
reply_with: process.Subject(StorageResult(Int)),
103+
)
99104
Shutdown(reply_with: process.Subject(Nil))
100105
}
101106

@@ -364,6 +369,20 @@ pub fn owner_repositories(name: Name) -> repository.Repositories {
364369
})
365370
},
366371
),
372+
domain_event_log: repository.DomainEventLogRepository(
373+
append: fn(envelope) {
374+
call_owner_nil(name, fn(connection) {
375+
tx.with_transaction_connection(connection, fn(connection) {
376+
domain_event_log_db.append_event(connection, envelope)
377+
})
378+
})
379+
},
380+
count: fn() {
381+
call_owner_int(name, fn(connection) {
382+
domain_event_log_db.count_events(connection)
383+
})
384+
},
385+
),
367386
)
368387
}
369388

@@ -432,6 +451,11 @@ fn handle_owner_message(
432451
actor.continue(state)
433452
}
434453

454+
RunInt(operation, reply_with) -> {
455+
actor.send(reply_with, operation(state.connection))
456+
actor.continue(state)
457+
}
458+
435459
Shutdown(reply_with) -> {
436460
sqlite_connection.close(state.connection)
437461
actor.send(reply_with, Nil)
@@ -596,6 +620,15 @@ fn call_owner_optional_string(
596620
})
597621
}
598622

623+
fn call_owner_int(
624+
name: Name,
625+
operation: fn(Connection) -> StorageResult(Int),
626+
) -> StorageResult(Int) {
627+
actor.call(named_handle(name), waiting: owner_timeout_ms, sending: fn(reply) {
628+
RunInt(operation: operation, reply_with: reply)
629+
})
630+
}
631+
599632
fn find_recent_command_ack_connection(
600633
connection: Connection,
601634
idempotency_key: String,

0 commit comments

Comments
 (0)