diff --git a/DOCS/superpowers/specs/2026-06-10-spacesh-sp2-event-log-design.md b/DOCS/superpowers/specs/2026-06-10-spacesh-sp2-event-log-design.md new file mode 100644 index 0000000..95986f6 --- /dev/null +++ b/DOCS/superpowers/specs/2026-06-10-spacesh-sp2-event-log-design.md @@ -0,0 +1,172 @@ +# 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.