Files
spaceshell/DOCS/superpowers/specs/2026-06-10-spacesh-sp2-event-log-design.md
2026-06-10 06:31:26 +07:00

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 + exitwork/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):

#[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 — 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:

#[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 idsnext_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 snapshotrestore.

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.