# 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`/`Evt` variants in `spacesh-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`): ```rust #[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, // 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), Surface(SurfaceId), } ``` Additions to the existing `Cmd` enum (`message.rs`): - `EventLog { #[serde(default)] limit: Option }` → 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 }` — 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) ```rust pub struct EventLog { records: VecDeque, // 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` — assigns `next_id`, pushes back, evicts front if over `cap`, returns the record (clone) for broadcast. - `mark_read(&mut self, target: &MarkReadTarget) -> Vec` — flips matching records to `read=true`, returns the ids that actually changed (empty if none). - `unread_count(&self) -> u32`. - `recent(&self, limit: Option) -> Vec` — most-recent-first, capped. - `snapshot(&self) -> EventLogState` / `restore(state)` — for persistence (carries `records` + `next_id`). ### `spaceshd/src/event_store.rs` (new — persistence) Mirrors `state_store.rs`: ```rust #[derive(Default, Serialize, Deserialize)] pub struct EventLogState { pub version: u32, pub next_id: u64, pub records: Vec, } pub trait EventStore: Send + Sync { fn load(&self) -> Result; 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-` 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)`; new `DaemonEvt` variants `event` and `events_read` wired through `onDaemonEvent`. - **`App.tsx`**: feed is seeded from `getEventLog()` on connect and on reconnect; live-appended on `event`; read flags updated on `events_read`. `maybeNotify` fires on incoming `event`. Unread count derived from the mirror and passed to `TopBar`. - **`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) **and** `markEventsRead({target:"ids", value:[entryId]})`. - **`TopBar.tsx`**: `bell` shows a numeric badge = unread count (hidden at 0). --- ## Data flow 1. The status engine emits `Evt::State` / `Evt::Exit` exactly as today (unchanged). 2. A **recorder hook** in the server's event-dispatch path inspects each outgoing event: for `State` with kind ∈ {done, wait, error} or any `Exit`, it builds an `EventRecord` via `EventLog.record(...)` (denormalizing the surface's `agent_label` and the workspace's `name` from the registry at record time), calls `persister.mark_dirty(snapshot)`, and broadcasts `Evt::Event { record }`. 3. The `Focus` command handler additionally calls `EventLog.mark_read(Surface(id))`; if any ids changed it broadcasts `Evt::EventsRead { ids }` and marks dirty. 4. `MarkRead { target }` → `mark_read(target)` → broadcast `EventsRead` + mark dirty (no-op broadcast skipped when nothing changed). 5. On GUI connect: one `EventLog` request seeds the feed; thereafter live `Event` / `EventsRead` keep it in sync. 6. On daemon boot: `EventStore.load()` restores `records` + `next_id` into the `EventLog`. `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 to `events.corrupt-`, log starts empty (mirrors `state_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 `markEventsRead` for that id still applies). - **Monotonic ids** → `next_id` is persisted with the log, so ids are never reused after a restart; `EventsRead` references 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 `EventLog` fetch; live mirrors prune to the cap). - **No-op mark-read** → when `mark_read` changes 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 new `Cmd`/`Evt` variants (tag/format assertions like the existing tests). **Unit (`spaceshd/event_log.rs`)** - push beyond cap evicts oldest, length stays at cap; - `mark_read` by `Surface`, by `Ids`, by `All` flips exactly the right records and returns changed ids; - `unread_count` correctness 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`/`exit` event produces an `Evt::Event` broadcast and a persisted record; - `Focus` produces `Evt::EventsRead` for that surface's events; - cold restart (drop + reload via `EventStore`) restores the log and `next_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 per `CLAUDE.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.