e038ff307d
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
173 lines
9.7 KiB
Markdown
173 lines
9.7 KiB
Markdown
# 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<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)
|
|
```rust
|
|
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` — assigns `next_id`, pushes back, evicts front if over `cap`, returns the record (clone) for broadcast.
|
|
- `mark_read(&mut self, target: &MarkReadTarget) -> Vec<u64>` — flips matching records to `read=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 (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<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)`; 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-<ts>`, 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.
|