Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
9.7 KiB
spacesh SP2 — Persistent Event Log + Read-Model — Design
Status: Design (approved for plan authoring)
Date: 2026-06-10
Scope: Sub-project SP2 of the "frontend mocks → backend" decomposition. Backs the Event Center tabs (All/Unread/Errors), per-entry read state, and the top-bar bell badge with real daemon-owned data.
Problem
Today the Event Center feed lives only in GUI memory (App.tsx): it is rebuilt from incoming state/exit events and is lost on every GUI restart. The Unread tab and the bell badge are mocked. The daemon — the single source of truth that outlives the GUI — keeps no event history at all.
SP2 moves the event log into the daemon, persists it to disk so it survives a cold daemon restart, and gives every event a real read-flag so the Unread/Errors filters and the bell badge reflect actual state.
Decisions (locked during brainstorming)
| Question | Decision |
|---|---|
| Persistence depth | Persist to disk — survives cold daemon restart. |
| Read-model | Explicit + focus — read set on entry click, on focusing the event's surface, or "Mark all read". Badge = unread count. Unread tab = unread only. |
| Logged events | done / wait / error + exit — work/idle excluded as noise. |
| Retention | 1000 global ring buffer — oldest evicted. |
| Storage approach | Dedicated EventStore + ~/.spacesh/events.json (separate from state.json), mirroring the existing debounced atomic-write persister. |
Architecture invariants honored
- Daemon is the single source of truth. The event log and all read flags live in
spaceshd; GUI and CLI mirror it and never hold authoritative state. - One socket, one protocol. All additions are new
Cmd/Evtvariants inspacesh-proto; no new transport. - Extend through the bus. New behavior = new commands/events/subscribers; the spine does not move.
- Files that change together live together. The fast-changing event log gets its own module and file rather than bloating the slow-changing structure snapshot (
PersistState/state.json).
Components
spacesh-proto (new types + wire additions)
A new module event.rs (re-exported from lib.rs):
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EventKind { Done, Wait, Error, Exit }
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EventRecord {
pub id: u64, // monotonic, survives restart
pub surface_id: SurfaceId,
pub workspace_id: WorkspaceId,
pub workspace_name: String, // denormalized — feed survives workspace close
pub agent_label: Option<String>, // denormalized — feed survives surface close
pub kind: EventKind,
pub ts: u64, // unix epoch millis
pub read: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "target", content = "value", rename_all = "snake_case")]
pub enum MarkReadTarget {
All,
Ids(Vec<u64>),
Surface(SurfaceId),
}
Additions to the existing Cmd enum (message.rs):
EventLog { #[serde(default)] limit: Option<u32> }→ response data{ "events": [EventRecord, ...], "unread": u32 }, most-recent-first.MarkRead { target: MarkReadTarget }→ok.
Additions to the existing Evt enum:
Event { record: EventRecord }— pushed when a new loggable event is recorded.EventsRead { ids: Vec<u64> }— pushed when records transition to read (carries the affected ids, including the full set for "mark all").
spaceshd/src/event_log.rs (new — in-memory model)
pub struct EventLog {
records: VecDeque<EventRecord>, // cap 1000, oldest at front
next_id: u64,
cap: usize,
}
Methods (pure, unit-testable; no I/O):
record(&mut self, surface_id, workspace_id, workspace_name, agent_label, kind, ts) -> EventRecord— assignsnext_id, pushes back, evicts front if overcap, returns the record (clone) for broadcast.mark_read(&mut self, target: &MarkReadTarget) -> Vec<u64>— flips matching records toread=true, returns the ids that actually changed (empty if none).unread_count(&self) -> u32.recent(&self, limit: Option<u32>) -> Vec<EventRecord>— most-recent-first, capped.snapshot(&self) -> EventLogState/restore(state)— for persistence (carriesrecords+next_id).
spaceshd/src/event_store.rs (new — persistence)
Mirrors state_store.rs:
#[derive(Default, Serialize, Deserialize)]
pub struct EventLogState {
pub version: u32,
pub next_id: u64,
pub records: Vec<EventRecord>,
}
pub trait EventStore: Send + Sync {
fn load(&self) -> Result<EventLogState>;
fn save(&self, state: &EventLogState) -> Result<()>;
}
pub struct JsonEventStore { path: PathBuf } // ~/.spacesh/events.json
Same durability mechanics as JsonStateStore: write to .tmp, fsync, atomic rename; on parse failure back up to events.corrupt-<ts> and start empty. A dedicated debounced persister task is spawned with the same shape as persist::spawn (it may be generalized or duplicated — the plan decides; duplication is acceptable to keep the two stores independent).
GUI
socketBridge.ts:getEventLog(limit?)→{ events, unread };markEventsRead(target); newDaemonEvtvariantseventandevents_readwired throughonDaemonEvent.App.tsx: feed is seeded fromgetEventLog()on connect and on reconnect; live-appended onevent; read flags updated onevents_read.maybeNotifyfires on incomingevent. Unread count derived from the mirror and passed toTopBar.EventCenter.tsx: tabs filter the real data —Unread=!read,Errors=kind === "error". "Mark all read" →markEventsRead({target:"all"}). Entry click →focusSurface(id)(already marks the surface read server-side) andmarkEventsRead({target:"ids", value:[entryId]}).TopBar.tsx:bellshows a numeric badge = unread count (hidden at 0).
Data flow
- The status engine emits
Evt::State/Evt::Exitexactly as today (unchanged). - A recorder hook in the server's event-dispatch path inspects each outgoing event: for
Statewith kind ∈ {done, wait, error} or anyExit, it builds anEventRecordviaEventLog.record(...)(denormalizing the surface'sagent_labeland the workspace'snamefrom the registry at record time), callspersister.mark_dirty(snapshot), and broadcastsEvt::Event { record }. - The
Focuscommand handler additionally callsEventLog.mark_read(Surface(id)); if any ids changed it broadcastsEvt::EventsRead { ids }and marks dirty. MarkRead { target }→mark_read(target)→ broadcastEventsRead+ mark dirty (no-op broadcast skipped when nothing changed).- On GUI connect: one
EventLogrequest seeds the feed; thereafter liveEvent/EventsReadkeep it in sync. - On daemon boot:
EventStore.load()restoresrecords+next_idinto theEventLog.
unread is derivable from the records; the EventLog response includes it as a convenience, and the GUI also recomputes it from its mirror so the badge stays correct as live events arrive.
Error handling & edge cases
- Corrupt
events.json→ backed up toevents.corrupt-<ts>, log starts empty (mirrorsstate_store). - Closed surface/workspace → records persist with their denormalized labels and remain displayable. Clicking focus on a dead surface returns a daemon error; GUI ignores it (the
markEventsReadfor that id still applies). - Monotonic ids →
next_idis persisted with the log, so ids are never reused after a restart;EventsReadreferences stay valid. - Ring overflow → the oldest record is evicted on push; if it was unread the badge decreases naturally (GUI recomputes from its mirror after the eviction is reflected — evicted records simply drop off the next full
EventLogfetch; live mirrors prune to the cap). - No-op mark-read → when
mark_readchanges nothing, no event is broadcast and no dirty mark is made.
Testing strategy
Unit (spacesh-proto)
- serde round-trip for
EventRecord,EventKind,MarkReadTarget, and the newCmd/Evtvariants (tag/format assertions like the existing tests).
Unit (spaceshd/event_log.rs)
- push beyond cap evicts oldest, length stays at cap;
mark_readbySurface, byIds, byAllflips exactly the right records and returns changed ids;unread_countcorrectness across record/mark cycles;- id monotonicity preserved across
snapshot→restore.
Unit (spaceshd/event_store.rs)
- save→load round-trip;
- corrupt file is backed up and load returns empty;
- atomic write leaves no partial file.
Integration (spaceshd)
- a
done/error/exitevent produces anEvt::Eventbroadcast and a persisted record; FocusproducesEvt::EventsReadfor that surface's events;- cold restart (drop + reload via
EventStore) restores the log andnext_id.
Manual — add a scenario to DOCS/RUNNING.md: drive a panel to done/error, see the entry; restart the GUI → feed intact; cold-restart the daemon → feed still intact; click an entry / focus a panel → unread badge drops.
Out of scope (SP2)
- External notification delivery (Telegram/MAX) — that is SP5.
- The top-bar
search/settings/ account menu — search is SP3; settings/account are out of v1 perCLAUDE.md. - Configurable event-kind filtering — fixed set (done/wait/error/exit) for SP2.
- Per-workspace retention — global 1000 cap only.
Performance
Event recording is O(1) (deque push + optional pop). Persistence is debounced exactly like the structure snapshot. Events are low-frequency relative to the PTY output path, so this never touches the keypress/render budgets.