From 807eab3f6c6b4c69b7544a9a15ce24850214146f Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 10 Jun 2026 06:40:15 +0700 Subject: [PATCH] =?UTF-8?q?docs:=20SP2=20implementation=20plan=20=E2=80=94?= =?UTF-8?q?=20persistent=20event=20log=20+=20read-model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-10-spacesh-sp2-event-log.md | 1442 +++++++++++++++++ 1 file changed, 1442 insertions(+) create mode 100644 DOCS/superpowers/plans/2026-06-10-spacesh-sp2-event-log.md diff --git a/DOCS/superpowers/plans/2026-06-10-spacesh-sp2-event-log.md b/DOCS/superpowers/plans/2026-06-10-spacesh-sp2-event-log.md new file mode 100644 index 0000000..6a9a6f2 --- /dev/null +++ b/DOCS/superpowers/plans/2026-06-10-spacesh-sp2-event-log.md @@ -0,0 +1,1442 @@ +# spacesh SP2 — Persistent Event Log + Read-Model — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move the Event Center feed into the daemon as a disk-persisted, read-flagged event log so the Unread/Errors tabs and the top-bar bell badge reflect real daemon-owned state across GUI and daemon restarts. + +**Architecture:** A new `EventLog` (in-memory ring of 1000, monotonic ids) lives in `spaceshd` and is the single source of truth. A recorder hook in the server's event-dispatch path captures `done`/`wait`/`error` state transitions and `exit`, denormalizes the workspace name + agent label, and broadcasts a new `Evt::Event`. Reads are set explicitly (`MarkRead` command, entry click) or by focusing a surface (`Focus`), broadcasting `Evt::EventsRead`. A dedicated `JsonEventStore` persists `~/.spacesh/events.json` via the same debounced atomic-write pattern as `state.json`. GUI and CLI mirror the log; they never hold authoritative state. + +**Tech Stack:** Rust (tokio, serde, anyhow) for `spacesh-proto`/`spaceshd`; React + TypeScript + Tauri 2 for the app. + +**Design spec:** `DOCS/superpowers/specs/2026-06-10-spacesh-sp2-event-log-design.md` + +--- + +## File Structure + +**Create:** +- `crates/spacesh-proto/src/event.rs` — `EventKind`, `EventRecord`, `MarkReadTarget` + serde tests. +- `crates/spaceshd/src/event_log.rs` — `EventLog` model + `EventLogState` (serializable form). No I/O, fully unit-testable. +- `crates/spaceshd/src/event_store.rs` — `EventStore` trait, `JsonEventStore`, and the debounced `EventPersister` (mirrors `persist.rs`/`state_store.rs`). + +**Modify:** +- `crates/spacesh-proto/src/lib.rs` — register `event` module + re-exports. +- `crates/spacesh-proto/src/message.rs` — add `Cmd::EventLog`, `Cmd::MarkRead`, `Evt::Event`, `Evt::EventsRead` + serde tests. +- `crates/spaceshd/src/main.rs` — declare new modules; build `JsonEventStore`; pass it to `serve`. +- `crates/spaceshd/src/server.rs` — thread the event log + persister through `serve`/`router`/`handle_request`; recorder hook in `Exit`/`StateDetected`; `EventLog`/`MarkRead` command arms; `Focus` marks read. +- `app/src-tauri/src/bridge.rs` — `event_log` + `mark_read` Tauri commands. +- `app/src-tauri/src/lib.rs` — register the two new commands. +- `app/src/socketBridge.ts` — `EventRecord` type, `getEventLog`, `markEventsRead`, new `DaemonEvt` variants. +- `app/src/App.tsx` — seed feed from daemon, live-update, derive unread, pass to TopBar. +- `app/src/EventCenter.tsx` — real read/kind filters; wire mark-read calls; accept daemon feed shape. +- `app/src/TopBar.tsx` — numeric badge on `bell`. +- `DOCS/RUNNING.md` — SP2 manual test scenario. + +**Note on dependency direction (deviation from spec):** `EventLogState` lives in `event_log.rs` (the model's serializable form), and `event_store.rs` imports it. The store depends on the model, never the reverse. + +--- + +## Task 1: Proto event types + +**Files:** +- Create: `crates/spacesh-proto/src/event.rs` +- Modify: `crates/spacesh-proto/src/lib.rs:1-13` + +- [ ] **Step 1: Write the failing tests** + +Create `crates/spacesh-proto/src/event.rs`: + +```rust +use serde::{Deserialize, Serialize}; +use crate::ids::{SurfaceId, WorkspaceId}; + +/// The subset of activity that lands in the event log. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum EventKind { + Done, + Wait, + Error, + Exit, +} + +/// One logged event. Workspace name and agent label are denormalized so the +/// feed stays displayable after the surface or workspace is closed. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct EventRecord { + pub id: u64, + pub surface_id: SurfaceId, + pub workspace_id: WorkspaceId, + pub workspace_name: String, + #[serde(default)] + pub agent_label: Option, + pub kind: EventKind, + pub ts: u64, + pub read: bool, +} + +/// What a `mark_read` request targets. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "target", content = "value", rename_all = "snake_case")] +pub enum MarkReadTarget { + All, + Ids(Vec), + Surface(SurfaceId), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_kind_serializes_lowercase() { + assert_eq!(serde_json::to_string(&EventKind::Done).unwrap(), r#""done""#); + assert_eq!(serde_json::to_string(&EventKind::Exit).unwrap(), r#""exit""#); + } + + #[test] + fn event_record_round_trips() { + let r = EventRecord { + id: 7, + surface_id: SurfaceId("s_1".into()), + workspace_id: WorkspaceId("w_1".into()), + workspace_name: "infra".into(), + agent_label: Some("claude".into()), + kind: EventKind::Error, + ts: 1_700_000_000_000, + read: false, + }; + let back: EventRecord = serde_json::from_str(&serde_json::to_string(&r).unwrap()).unwrap(); + assert_eq!(back, r); + } + + #[test] + fn mark_read_target_variants_serialize() { + assert_eq!(serde_json::to_string(&MarkReadTarget::All).unwrap(), r#"{"target":"all"}"#); + assert_eq!( + serde_json::to_string(&MarkReadTarget::Ids(vec![1, 2])).unwrap(), + r#"{"target":"ids","value":[1,2]}"# + ); + let s = MarkReadTarget::Surface(SurfaceId("s_9".into())); + assert_eq!(serde_json::to_string(&s).unwrap(), r#"{"target":"surface","value":"s_9"}"#); + let back: MarkReadTarget = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(back, s); + } +} +``` + +- [ ] **Step 2: Register the module** + +In `crates/spacesh-proto/src/lib.rs`, add `pub mod event;` after `pub mod codec;` and extend the re-exports. Result: + +```rust +pub mod codec; +pub mod event; +pub mod ids; +pub mod layout; +pub mod message; +pub mod status; +pub mod workspace; + +pub use event::{EventKind, EventRecord, MarkReadTarget}; +pub use ids::{GroupId, SurfaceId, WorkspaceId}; +pub use layout::{LayoutNode, Orient}; +pub use message::{Cmd, Envelope, ErrorBody, Evt}; +pub use status::SurfaceState; +pub use workspace::{Group, SurfaceSpec, SurfaceView, Workspace, WorkspaceView}; +``` + +- [ ] **Step 3: Run the tests** + +Run: `cargo test -p spacesh-proto event::` +Expected: PASS (3 tests). + +- [ ] **Step 4: Commit** + +```bash +git add crates/spacesh-proto/src/event.rs crates/spacesh-proto/src/lib.rs +git commit -m "feat(proto): EventKind, EventRecord, MarkReadTarget" +``` + +--- + +## Task 2: Proto Cmd/Evt additions + +**Files:** +- Modify: `crates/spacesh-proto/src/message.rs:1-5` (imports), `:118-121` (Cmd tail), `:126-137` (Evt) + +- [ ] **Step 1: Add the imports** + +At the top of `crates/spacesh-proto/src/message.rs`, extend the proto-crate imports so the new variants can name the event types: + +```rust +use crate::event::{EventRecord, MarkReadTarget}; +``` + +(Place it alongside the existing `use crate::ids::...;` lines.) + +- [ ] **Step 2: Add the Cmd variants** + +In the `Cmd` enum, immediately before `Status,` add: + +```rust + EventLog { + #[serde(default, skip_serializing_if = "Option::is_none")] + limit: Option, + }, + MarkRead { target: MarkReadTarget }, +``` + +- [ ] **Step 3: Add the Evt variants** + +In the `Evt` enum, after `State { surface_id: SurfaceId, state: SurfaceState },` add: + +```rust + Event { record: EventRecord }, + EventsRead { ids: Vec }, +``` + +- [ ] **Step 4: Write the failing tests** + +Append to the `tests` module in `message.rs`: + +```rust + #[test] + fn event_log_cmd_round_trips() { + let env = Envelope::Req { id: 1, cmd: Cmd::EventLog { limit: Some(50) } }; + let j = serde_json::to_string(&env).unwrap(); + assert!(j.contains(r#""cmd":"event_log""#)); + let back: Envelope = serde_json::from_str(&j).unwrap(); + assert_eq!(back, env); + } + + #[test] + fn mark_read_cmd_round_trips() { + let env = Envelope::Req { + id: 2, + cmd: Cmd::MarkRead { target: crate::event::MarkReadTarget::All }, + }; + let j = serde_json::to_string(&env).unwrap(); + assert!(j.contains(r#""cmd":"mark_read""#)); + let back: Envelope = serde_json::from_str(&j).unwrap(); + assert_eq!(back, env); + } + + #[test] + fn event_evt_round_trips() { + let evt = Envelope::Evt(Evt::Event { + record: crate::event::EventRecord { + id: 3, + surface_id: SurfaceId("s_1".into()), + workspace_id: WorkspaceId("w_1".into()), + workspace_name: "p".into(), + agent_label: None, + kind: crate::event::EventKind::Done, + ts: 1, + read: false, + }, + }); + let j = serde_json::to_string(&evt).unwrap(); + assert!(j.contains(r#""evt":"event""#)); + let back: Envelope = serde_json::from_str(&j).unwrap(); + assert_eq!(back, evt); + } + + #[test] + fn events_read_evt_round_trips() { + let evt = Envelope::Evt(Evt::EventsRead { ids: vec![1, 2, 3] }); + let j = serde_json::to_string(&evt).unwrap(); + assert!(j.contains(r#""evt":"events_read""#)); + let back: Envelope = serde_json::from_str(&j).unwrap(); + assert_eq!(back, evt); + } +``` + +- [ ] **Step 5: Run the tests** + +Run: `cargo test -p spacesh-proto message::` +Expected: PASS (existing tests + 4 new). + +- [ ] **Step 6: Commit** + +```bash +git add crates/spacesh-proto/src/message.rs +git commit -m "feat(proto): EventLog/MarkRead commands and Event/EventsRead events" +``` + +--- + +## Task 3: EventLog model + +**Files:** +- Create: `crates/spaceshd/src/event_log.rs` +- Modify: `crates/spaceshd/src/main.rs:1-8` (module list) + +- [ ] **Step 1: Write the failing tests + implementation** + +Create `crates/spaceshd/src/event_log.rs`: + +```rust +use std::collections::VecDeque; +use serde::{Deserialize, Serialize}; +use spacesh_proto::event::{EventKind, EventRecord, MarkReadTarget}; +use spacesh_proto::ids::{SurfaceId, WorkspaceId}; + +/// Serializable form of the log, used for persistence. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct EventLogState { + pub version: u32, + pub next_id: u64, + #[serde(default)] + pub records: Vec, +} + +/// In-memory event log: a capped ring with monotonic ids. +pub struct EventLog { + records: VecDeque, + next_id: u64, + cap: usize, +} + +impl EventLog { + pub fn new(cap: usize) -> Self { + Self { records: VecDeque::new(), next_id: 1, cap } + } + + /// Rebuild from a persisted snapshot, clamping to `cap` (keeping newest). + pub fn restore(state: EventLogState, cap: usize) -> Self { + let mut records: VecDeque = state.records.into_iter().collect(); + while records.len() > cap { + records.pop_front(); + } + let next_id = state.next_id.max(1); + Self { records, next_id, cap } + } + + /// Append a new event. Evicts the oldest when over capacity. Returns the + /// stored record (with its assigned id) for broadcasting. + #[allow(clippy::too_many_arguments)] + pub fn record( + &mut self, + surface_id: SurfaceId, + workspace_id: WorkspaceId, + workspace_name: String, + agent_label: Option, + kind: EventKind, + ts: u64, + ) -> EventRecord { + let rec = EventRecord { + id: self.next_id, + surface_id, + workspace_id, + workspace_name, + agent_label, + kind, + ts, + read: false, + }; + self.next_id += 1; + self.records.push_back(rec.clone()); + while self.records.len() > self.cap { + self.records.pop_front(); + } + rec + } + + /// Flip matching records to read. Returns the ids that actually changed. + pub fn mark_read(&mut self, target: &MarkReadTarget) -> Vec { + let mut changed = Vec::new(); + for r in self.records.iter_mut() { + if r.read { + continue; + } + let hit = match target { + MarkReadTarget::All => true, + MarkReadTarget::Ids(ids) => ids.contains(&r.id), + MarkReadTarget::Surface(sid) => &r.surface_id == sid, + }; + if hit { + r.read = true; + changed.push(r.id); + } + } + changed + } + + pub fn unread_count(&self) -> u32 { + self.records.iter().filter(|r| !r.read).count() as u32 + } + + /// Most-recent-first, optionally capped to `limit`. + pub fn recent(&self, limit: Option) -> Vec { + let iter = self.records.iter().rev().cloned(); + match limit { + Some(n) => iter.take(n as usize).collect(), + None => iter.collect(), + } + } + + pub fn snapshot(&self) -> EventLogState { + EventLogState { + version: 1, + next_id: self.next_id, + records: self.records.iter().cloned().collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rec(log: &mut EventLog, sid: &str, kind: EventKind) -> EventRecord { + log.record( + SurfaceId(sid.into()), + WorkspaceId("w_1".into()), + "infra".into(), + Some("claude".into()), + kind, + 1, + ) + } + + #[test] + fn record_assigns_monotonic_ids() { + let mut log = EventLog::new(10); + assert_eq!(rec(&mut log, "s_1", EventKind::Done).id, 1); + assert_eq!(rec(&mut log, "s_1", EventKind::Wait).id, 2); + } + + #[test] + fn push_beyond_cap_evicts_oldest() { + let mut log = EventLog::new(2); + rec(&mut log, "s_1", EventKind::Done); // id 1 + rec(&mut log, "s_2", EventKind::Done); // id 2 + rec(&mut log, "s_3", EventKind::Done); // id 3, evicts id 1 + let ids: Vec = log.recent(None).iter().map(|r| r.id).collect(); + assert_eq!(ids, vec![3, 2]); // newest first, id 1 gone + } + + #[test] + fn mark_read_by_surface_then_ids_then_all() { + let mut log = EventLog::new(10); + rec(&mut log, "s_1", EventKind::Done); // 1 + rec(&mut log, "s_2", EventKind::Error); // 2 + rec(&mut log, "s_1", EventKind::Wait); // 3 + assert_eq!(log.unread_count(), 3); + + let changed = log.mark_read(&MarkReadTarget::Surface(SurfaceId("s_1".into()))); + assert_eq!(changed, vec![1, 3]); + assert_eq!(log.unread_count(), 1); + + // Re-marking the same surface changes nothing. + assert!(log.mark_read(&MarkReadTarget::Surface(SurfaceId("s_1".into()))).is_empty()); + + let changed = log.mark_read(&MarkReadTarget::Ids(vec![2, 999])); + assert_eq!(changed, vec![2]); + assert_eq!(log.unread_count(), 0); + + assert!(log.mark_read(&MarkReadTarget::All).is_empty()); + } + + #[test] + fn snapshot_restore_preserves_next_id_and_records() { + let mut log = EventLog::new(10); + rec(&mut log, "s_1", EventKind::Done); + rec(&mut log, "s_2", EventKind::Done); + let snap = log.snapshot(); + assert_eq!(snap.next_id, 3); + + let restored = EventLog::restore(snap, 10); + assert_eq!(restored.recent(None).len(), 2); + // Next recorded id continues from 3, no reuse. + let mut restored = restored; + assert_eq!(rec(&mut restored, "s_3", EventKind::Done).id, 3); + } + + #[test] + fn restore_clamps_to_cap_keeping_newest() { + let state = EventLogState { + version: 1, + next_id: 4, + records: vec![ + EventRecord { id: 1, surface_id: SurfaceId("a".into()), workspace_id: WorkspaceId("w".into()), workspace_name: "x".into(), agent_label: None, kind: EventKind::Done, ts: 1, read: false }, + EventRecord { id: 2, surface_id: SurfaceId("a".into()), workspace_id: WorkspaceId("w".into()), workspace_name: "x".into(), agent_label: None, kind: EventKind::Done, ts: 1, read: false }, + EventRecord { id: 3, surface_id: SurfaceId("a".into()), workspace_id: WorkspaceId("w".into()), workspace_name: "x".into(), agent_label: None, kind: EventKind::Done, ts: 1, read: false }, + ], + }; + let log = EventLog::restore(state, 2); + let ids: Vec = log.recent(None).iter().map(|r| r.id).collect(); + assert_eq!(ids, vec![3, 2]); + } +} +``` + +- [ ] **Step 2: Declare the module** + +In `crates/spaceshd/src/main.rs`, add `mod event_log;` to the existing module list (the block `mod hooks; … mod surface;` at the top). Insert it so the list stays readable, e.g. right after `mod event_store;` once that exists, or before `mod hooks;` now: + +```rust +mod event_log; +``` + +- [ ] **Step 3: Run the tests** + +Run: `cargo test -p spaceshd event_log::` +Expected: PASS (5 tests). + +- [ ] **Step 4: Commit** + +```bash +git add crates/spaceshd/src/event_log.rs crates/spaceshd/src/main.rs +git commit -m "feat(daemon): EventLog ring model with read-flags" +``` + +--- + +## Task 4: EventStore persistence + debounced persister + +**Files:** +- Create: `crates/spaceshd/src/event_store.rs` +- Modify: `crates/spaceshd/src/main.rs` (module list) + +- [ ] **Step 1: Write the failing tests + implementation** + +Create `crates/spaceshd/src/event_store.rs`. This mirrors `state_store.rs` (atomic write, corrupt-backup) and `persist.rs` (debounced coalescing task): + +```rust +use std::path::PathBuf; +use std::sync::Arc; +use anyhow::Result; +use tokio::sync::mpsc; +use tokio::time::{Duration, Instant}; +use crate::event_log::EventLogState; + +pub trait EventStore: Send + Sync { + fn load(&self) -> Result; + fn save(&self, state: &EventLogState) -> Result<()>; +} + +/// JSON file store with atomic write (temp + fsync + rename) and corrupt backup. +pub struct JsonEventStore { + path: PathBuf, +} + +impl JsonEventStore { + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + fn backup_corrupt(&self, ts: u128) { + let bak = self.path.with_extension(format!("corrupt-{ts}")); + let _ = std::fs::rename(&self.path, bak); + } +} + +impl EventStore for JsonEventStore { + fn load(&self) -> Result { + if !self.path.exists() { + return Ok(EventLogState { version: 1, next_id: 1, records: vec![] }); + } + let bytes = std::fs::read(&self.path)?; + match serde_json::from_slice::(&bytes) { + Ok(state) => Ok(state), + Err(_) => { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + self.backup_corrupt(ts); + Ok(EventLogState { version: 1, next_id: 1, records: vec![] }) + } + } + } + + fn save(&self, state: &EventLogState) -> Result<()> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + let tmp = self.path.with_extension("json.tmp"); + let bytes = serde_json::to_vec_pretty(state)?; + std::fs::write(&tmp, &bytes)?; + let f = std::fs::File::open(&tmp)?; + f.sync_all()?; + std::fs::rename(&tmp, &self.path)?; + Ok(()) + } +} + +/// Handle the recorder uses to request a debounced persist. +#[derive(Clone)] +pub struct EventPersister { + tx: mpsc::Sender, +} + +impl EventPersister { + pub fn mark_dirty(&self, state: EventLogState) { + let _ = self.tx.try_send(state); + } +} + +/// Spawn the debounce task; coalesces a burst into one save. +pub fn spawn(store: Arc, debounce: Duration) -> EventPersister { + let (tx, mut rx) = mpsc::channel::(64); + tokio::spawn(async move { + let mut latest: Option = None; + let mut deadline: Option = None; + loop { + let timer = async { + match deadline { + Some(d) => tokio::time::sleep_until(d).await, + None => std::future::pending::<()>().await, + } + }; + tokio::select! { + msg = rx.recv() => { + match msg { + Some(state) => { + latest = Some(state); + deadline = Some(Instant::now() + debounce); + } + None => { + if let Some(s) = latest.take() { let _ = store.save(&s); } + break; + } + } + } + _ = timer => { + if let Some(s) = latest.take() { let _ = store.save(&s); } + deadline = None; + } + } + } + }); + EventPersister { tx } +} + +#[cfg(test)] +mod tests { + use super::*; + use spacesh_proto::event::{EventKind, EventRecord}; + use spacesh_proto::ids::{SurfaceId, WorkspaceId}; + + fn tmp_file(name: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + let n = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + p.push(format!("spacesh-events-{name}-{n}.json")); + p + } + + fn sample() -> EventLogState { + EventLogState { + version: 1, + next_id: 2, + records: vec![EventRecord { + id: 1, + surface_id: SurfaceId("s_1".into()), + workspace_id: WorkspaceId("w_1".into()), + workspace_name: "infra".into(), + agent_label: Some("claude".into()), + kind: EventKind::Done, + ts: 1, + read: false, + }], + } + } + + #[test] + fn save_then_load_round_trips() { + let path = tmp_file("roundtrip"); + let store = JsonEventStore::new(path.clone()); + store.save(&sample()).unwrap(); + assert_eq!(store.load().unwrap(), sample()); + let _ = std::fs::remove_file(path); + } + + #[test] + fn missing_file_loads_empty() { + let store = JsonEventStore::new(tmp_file("missing")); + let s = store.load().unwrap(); + assert_eq!(s.next_id, 1); + assert!(s.records.is_empty()); + } + + #[test] + fn corrupt_file_is_backed_up_and_load_returns_empty() { + let path = tmp_file("corrupt"); + std::fs::write(&path, b"{ not valid json").unwrap(); + let store = JsonEventStore::new(path.clone()); + let s = store.load().unwrap(); + assert!(s.records.is_empty()); + assert!(!path.exists()); + let _ = std::fs::remove_file(path); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn burst_coalesces_to_one_save() { + struct Counting { saves: std::sync::atomic::AtomicUsize } + impl EventStore for Counting { + fn load(&self) -> Result { Ok(EventLogState::default()) } + fn save(&self, _s: &EventLogState) -> Result<()> { + self.saves.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + } + let store = Arc::new(Counting { saves: std::sync::atomic::AtomicUsize::new(0) }); + let p = spawn(store.clone(), Duration::from_millis(80)); + for v in 1..=5u64 { + let mut s = EventLogState::default(); + s.next_id = v; + p.mark_dirty(s); + tokio::time::sleep(Duration::from_millis(10)).await; + } + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!(store.saves.load(std::sync::atomic::Ordering::SeqCst), 1); + } +} +``` + +- [ ] **Step 2: Declare the module** + +In `crates/spaceshd/src/main.rs` add: + +```rust +mod event_store; +``` + +- [ ] **Step 3: Run the tests** + +Run: `cargo test -p spaceshd event_store::` +Expected: PASS (4 tests). + +- [ ] **Step 4: Commit** + +```bash +git add crates/spaceshd/src/event_store.rs crates/spaceshd/src/main.rs +git commit -m "feat(daemon): JsonEventStore + debounced EventPersister" +``` + +--- + +## Task 5: Wire the event store into serve/router + +This task threads the log + persister through the server with **no behavior yet** (no recording, no new commands). It must compile and keep existing tests green. + +**Files:** +- Modify: `crates/spaceshd/src/main.rs:47-59` (run_daemon) +- Modify: `crates/spaceshd/src/server.rs:12` (imports), `:38-61` (serve), `:112-121` (router signature + EventLog construction) + +- [ ] **Step 1: Build the event store in `run_daemon`** + +In `crates/spaceshd/src/main.rs`, replace the body of `run_daemon` from the `state_path` line through the `serve` call with: + +```rust + let state_path = lifecycle::spacesh_dir()?.join("state.json"); + let store: std::sync::Arc = + std::sync::Arc::new(state_store::JsonStateStore::new(state_path)); + let events_path = lifecycle::spacesh_dir()?.join("events.json"); + let event_store: std::sync::Arc = + std::sync::Arc::new(event_store::JsonEventStore::new(events_path)); + eprintln!("spaceshd listening on {}", sock.display()); + server::serve(&sock, store, event_store).await +``` + +- [ ] **Step 2: Extend the server imports** + +In `crates/spaceshd/src/server.rs`, add near the existing `use crate::persist::{self, Persister};` line: + +```rust +use crate::event_log::EventLog; +use crate::event_store::{self, EventPersister, EventStore}; +``` + +- [ ] **Step 3: Accept the event store in `serve` and spawn its persister** + +Change the `serve` signature and the router spawn. Replace lines 38 and 59-61 region so it reads: + +```rust +pub async fn serve(socket: &Path, store: Arc, event_store: Arc) -> Result<()> { +``` + +and, where the persister/initial/router are set up: + +```rust + let persister = persist::spawn(store.clone(), Duration::from_millis(500)); + let initial = store.load().unwrap_or_default(); + let event_persister = event_store::spawn(event_store.clone(), Duration::from_millis(500)); + let event_initial = event_store.load().unwrap_or_default(); + let shutdown = tokio::spawn(router( + router_rx, router_tx.clone(), exit_tx, state_tx, + persister, initial, event_persister, event_initial, + )); +``` + +- [ ] **Step 4: Extend the `router` signature and construct the EventLog** + +Change the `router` function signature (around line 112) to add the two new params, and build the log right after `reg.restore(initial);`: + +```rust +async fn router( + mut rx: mpsc::Receiver, + router_tx: mpsc::Sender, + exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>, + state_tx: mpsc::UnboundedSender<(SurfaceId, SurfaceState)>, + persister: Persister, + initial: crate::state_store::PersistState, + event_persister: EventPersister, + event_initial: crate::event_log::EventLogState, +) { + let mut reg = Registry::new(); + reg.restore(initial); + let mut event_log = EventLog::restore(event_initial, 1000); + let _ = &event_persister; // wired in Task 6/7 + let _ = &mut event_log; // wired in Task 6/7 +``` + +(The two `let _ =` lines silence unused warnings until Tasks 6–7 consume them; remove them in Task 6.) + +- [ ] **Step 5: Fix the existing integration-test call sites** + +The in-crate tests call `serve(...)`. Find them: + +Run: `grep -rn "serve(" crates/spaceshd/src/server.rs` + +For each test that calls `server::serve(&path, store)` (or `serve(&path, store)`), add an in-memory event store argument. Use the same `JsonEventStore` pointed at a temp path (tests already create temp dirs). Concretely, where a test builds `store`, add: + +```rust + let event_store: std::sync::Arc = + std::sync::Arc::new(crate::event_store::JsonEventStore::new( + std::env::temp_dir().join(format!("spacesh-test-events-{}.json", + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos())), + )); +``` + +and pass it as the third `serve` argument. + +- [ ] **Step 6: Build and run the daemon tests** + +Run: `cargo test -p spaceshd` +Expected: PASS (all existing tests still green; new modules compile and are wired). + +- [ ] **Step 7: Commit** + +```bash +git add crates/spaceshd/src/main.rs crates/spaceshd/src/server.rs +git commit -m "wire(daemon): thread EventLog + EventPersister through serve/router" +``` + +--- + +## Task 6: Recorder hook (Exit / StateDetected → Evt::Event) + +**Files:** +- Modify: `crates/spaceshd/src/server.rs:147-158` (Exit/StateDetected arms), add a `record_event` helper + `now_millis` + a `kind_for_state` mapper near `broadcast_evt`. + +- [ ] **Step 1: Add the helpers** + +In `crates/spaceshd/src/server.rs`, after the `broadcast_evt` function add: + +```rust +fn now_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Which state transitions are worth logging. work/idle are noise → None. +fn kind_for_state(state: SurfaceState) -> Option { + use spacesh_proto::event::EventKind; + match state { + SurfaceState::Done => Some(EventKind::Done), + SurfaceState::Wait => Some(EventKind::Wait), + SurfaceState::Error => Some(EventKind::Error), + SurfaceState::Work | SurfaceState::Idle => None, + } +} + +/// Record one event (denormalizing workspace name + agent label), persist, broadcast. +fn record_event( + reg: &Registry, + log: &mut EventLog, + persister: &EventPersister, + clients: &HashMap, + sid: &SurfaceId, + kind: spacesh_proto::event::EventKind, +) { + let Some(ws_id) = reg.workspace_of(sid) else { return }; + let ws_name = reg.workspace(&ws_id).map(|w| w.name.clone()).unwrap_or_default(); + let agent = reg.surface_spec(sid).and_then(|s| s.agent_label); + let rec = log.record(sid.clone(), ws_id, ws_name, agent, kind, now_millis()); + persister.mark_dirty(log.snapshot()); + broadcast_evt(clients, &Envelope::Evt(Evt::Event { record: rec })); +} +``` + +- [ ] **Step 2: Call the recorder from Exit and StateDetected** + +Remove the two `let _ = ...event...;` placeholder lines added in Task 5. Then update the two arms in `router`: + +```rust + ServerMsg::Exit { surface_id, code } => { + reg.mark_stopped(&surface_id); + reg.drop_state(&surface_id); + record_event(®, &mut event_log, &event_persister, &clients, + &surface_id, spacesh_proto::event::EventKind::Exit); + let evt = Envelope::Evt(Evt::Exit { surface_id: surface_id.clone(), code }); + broadcast_evt(&clients, &evt); + } + ServerMsg::StateDetected { surface_id, state } => { + if reg.is_running(&surface_id) { + reg.set_state(&surface_id, state); + broadcast_evt(&clients, &Envelope::Evt(Evt::State { surface_id: surface_id.clone(), state })); + if let Some(kind) = kind_for_state(state) { + record_event(®, &mut event_log, &event_persister, &clients, &surface_id, kind); + } + } + } +``` + +Note: `Evt::State` now clones `surface_id` because we use it again for recording. + +- [ ] **Step 3: Write the failing integration test** + +Add to the `tests` module in `server.rs`. This drives a state transition and asserts an `Evt::Event` is broadcast. Model it on the existing socket integration tests (which use `req`/connect helpers and `crate::test_support::serial()`): + +```rust + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn done_state_emits_event_record() { + let _serial = crate::test_support::serial(); + let (path, mut s, ws_id) = setup_with_workspace().await; // helper below + // Create a shell surface. + let r = req(&mut s, 1, Cmd::NewSurface { workspace_id: ws_id.clone(), command: None, args: vec![], cols: 80, rows: 24 }).await; + let sid = r["surface_id"].as_str().unwrap().to_string(); + + // Drive a Done state via the SetState command path. + let _ = req(&mut s, 2, Cmd::SetState { surface_id: SurfaceId(sid.clone()), state: SurfaceState::Done }).await; + + // The daemon should broadcast an Evt::Event for this surface. + let evt = next_evt_matching(&mut s, |e| matches!(e, Evt::Event { record } if record.surface_id.0 == sid)).await; + if let Evt::Event { record } = evt { + assert_eq!(record.kind, spacesh_proto::event::EventKind::Done); + assert!(!record.read); + } else { panic!("expected Evt::Event"); } + + cleanup(path).await; + } +``` + +**Implementer note:** `setup_with_workspace`, `req`, `next_evt_matching`, and `cleanup` are test scaffolding. If equivalents already exist in the `server.rs` test module, reuse them and adjust names. If not, add minimal helpers next to the existing connect/`req` helpers: `setup_with_workspace` connects a client, sends `Cmd::Open { path: temp }`, returns `(temp_path, stream, workspace_id)`; `next_evt_matching` reads frames until an `Envelope::Evt` matches the predicate (skipping `res`/other evts), with a `tokio::time::timeout` of 2s that panics on elapse. Verify the daemon's `SetState` command actually routes through `StateDetected` (it goes through the state channel); if `SetState` short-circuits elsewhere, drive the state by sending `Cmd::SetState` and confirm via the existing state-event test's mechanism. + +- [ ] **Step 4: Run the test** + +Run: `cargo test -p spaceshd done_state_emits_event_record -- --nocapture` +Expected: PASS. + +- [ ] **Step 5: Run the full daemon suite** + +Run: `cargo test -p spaceshd` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/spaceshd/src/server.rs +git commit -m "feat(daemon): record done/wait/error/exit into the event log" +``` + +--- + +## Task 7: EventLog / MarkRead commands + Focus marks read + +**Files:** +- Modify: `crates/spaceshd/src/server.rs` — `handle_request` signature + call site (`:159-161`), the `Cmd::Focus` arm (`:494`), and add `Cmd::EventLog`/`Cmd::MarkRead` arms before `Cmd::Status` (`:524`). + +- [ ] **Step 1: Pass the log + persister into `handle_request`** + +Update the call site in `router` (the `ServerMsg::Request` arm): + +```rust + ServerMsg::Request { id, cmd, client, out } => { + handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, + &router_tx, &exit_tx, &state_tx, &persister, + &mut event_log, &event_persister).await; + } +``` + +Extend the `handle_request` signature (after `persister: &Persister,`): + +```rust + persister: &Persister, + event_log: &mut EventLog, + event_persister: &EventPersister, +) { +``` + +- [ ] **Step 2: Make Focus mark the surface's events read** + +Replace the existing no-op `Cmd::Focus` arm: + +```rust + Cmd::Focus { surface_id } => { + let ids = event_log.mark_read(&spacesh_proto::event::MarkReadTarget::Surface(surface_id.clone())); + if !ids.is_empty() { + event_persister.mark_dirty(event_log.snapshot()); + broadcast_evt(clients, &Envelope::Evt(Evt::EventsRead { ids })); + } + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } +``` + +- [ ] **Step 3: Add the EventLog and MarkRead arms** + +Immediately before `Cmd::Status => {`: + +```rust + Cmd::EventLog { limit } => { + let events = event_log.recent(limit); + let unread = event_log.unread_count(); + let _ = out.send(ok(id, serde_json::json!({ "events": events, "unread": unread }))).await; + } + + Cmd::MarkRead { target } => { + let ids = event_log.mark_read(&target); + if !ids.is_empty() { + event_persister.mark_dirty(event_log.snapshot()); + broadcast_evt(clients, &Envelope::Evt(Evt::EventsRead { ids })); + } + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } +``` + +- [ ] **Step 4: Write the failing integration test** + +Add to the `server.rs` tests module: + +```rust + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn event_log_query_and_mark_read() { + let _serial = crate::test_support::serial(); + let (path, mut s, ws_id) = setup_with_workspace().await; + let r = req(&mut s, 1, Cmd::NewSurface { workspace_id: ws_id.clone(), command: None, args: vec![], cols: 80, rows: 24 }).await; + let sid = r["surface_id"].as_str().unwrap().to_string(); + let _ = req(&mut s, 2, Cmd::SetState { surface_id: SurfaceId(sid.clone()), state: SurfaceState::Error }).await; + // Drain the broadcast Evt::Event so the next reads are deterministic. + let _ = next_evt_matching(&mut s, |e| matches!(e, Evt::Event { .. })).await; + + // Query the log. + let log = req(&mut s, 3, Cmd::EventLog { limit: None }).await; + assert_eq!(log["unread"].as_u64().unwrap(), 1); + let first_id = log["events"][0]["id"].as_u64().unwrap(); + + // Mark it read by id. + let _ = req(&mut s, 4, Cmd::MarkRead { target: spacesh_proto::event::MarkReadTarget::Ids(vec![first_id]) }).await; + let read_evt = next_evt_matching(&mut s, |e| matches!(e, Evt::EventsRead { .. })).await; + if let Evt::EventsRead { ids } = read_evt { assert_eq!(ids, vec![first_id]); } else { panic!(); } + + let log = req(&mut s, 5, Cmd::EventLog { limit: None }).await; + assert_eq!(log["unread"].as_u64().unwrap(), 0); + + cleanup(path).await; + } +``` + +- [ ] **Step 5: Run the tests** + +Run: `cargo test -p spaceshd event_log_query_and_mark_read -- --nocapture` +Expected: PASS. + +Run: `cargo test -p spaceshd` +Expected: PASS (whole daemon suite). + +- [ ] **Step 6: Commit** + +```bash +git add crates/spaceshd/src/server.rs +git commit -m "feat(daemon): EventLog/MarkRead commands; Focus marks surface read" +``` + +--- + +## Task 8: Tauri bridge commands + +**Files:** +- Modify: `app/src-tauri/src/bridge.rs` (after the `status` command, ~`:230`) +- Modify: `app/src-tauri/src/lib.rs:30-50` (handler registration) + +- [ ] **Step 1: Add the two commands** + +In `app/src-tauri/src/bridge.rs`, after the `status` command, add: + +```rust +#[tauri::command] +pub async fn event_log(state: BridgeState<'_>, limit: Option) -> Result { + data_of(state.request(Cmd::EventLog { limit }).await.map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn mark_read(state: BridgeState<'_>, target: Value) -> Result { + let target = serde_json::from_value(target).map_err(|e| e.to_string())?; + data_of(state.request(Cmd::MarkRead { target }).await.map_err(|e| e.to_string())?) +} +``` + +(`Cmd` is already imported in this file; `MarkReadTarget` is inferred by `from_value` from the `Cmd::MarkRead { target }` field type — no extra import needed.) + +- [ ] **Step 2: Register the handlers** + +In `app/src-tauri/src/lib.rs`, add to the `tauri::generate_handler![...]` list (after `bridge::focus,`): + +```rust + bridge::event_log, + bridge::mark_read, +``` + +- [ ] **Step 3: Build the Tauri crate** + +Run: `cd app/src-tauri && cargo build` +Expected: compiles clean (no warnings about unused commands). + +- [ ] **Step 4: Commit** + +```bash +git add app/src-tauri/src/bridge.rs app/src-tauri/src/lib.rs +git commit -m "feat(app): event_log and mark_read bridge commands" +``` + +--- + +## Task 9: GUI socketBridge + App wiring + +**Files:** +- Modify: `app/src/socketBridge.ts` (types + functions + `DaemonEvt`) +- Modify: `app/src/App.tsx` + +- [ ] **Step 1: Add the bridge types and functions** + +In `app/src/socketBridge.ts`, add near the other interfaces: + +```ts +export interface EventRecord { + id: number; + surface_id: string; + workspace_id: string; + workspace_name: string; + agent_label: string | null; + kind: "done" | "wait" | "error" | "exit"; + ts: number; + read: boolean; +} + +export type MarkReadTarget = + | { target: "all" } + | { target: "ids"; value: number[] } + | { target: "surface"; value: string }; + +export async function getEventLog(limit?: number): Promise<{ events: EventRecord[]; unread: number }> { + return await invoke<{ events: EventRecord[]; unread: number }>("event_log", { limit: limit ?? null }); +} + +export async function markEventsRead(target: MarkReadTarget): Promise { + await invoke("mark_read", { target }); +} +``` + +Extend the `DaemonEvt` union with the two new variants: + +```ts + | { evt: "event"; data: { record: EventRecord } } + | { evt: "events_read"; data: { ids: number[] } } +``` + +- [ ] **Step 2: Replace App's in-memory feed with the daemon-sourced log** + +In `app/src/App.tsx`: + +1. Update the import from `socketBridge` to add `getEventLog, markEventsRead` and `EventRecord`. +2. Replace the `feed`/`feedId` state. The feed is now `EventRecord[]` and there is an `unread` count: + +```tsx + const [events, setEvents] = useState([]); +``` + +3. In `refresh` (or a dedicated effect), seed the log once on connect: + +```tsx + const seedEvents = useCallback(async () => { + const log = await getEventLog(); + setEvents(log.events); + }, []); +``` + +Call `void seedEvents();` inside the initial `useEffect` (next to `void refresh();`) and in the reconnect handler. + +4. In the `onDaemonEvent` handler, replace the old feed-building for `state`/`exit` with handlers for the new daemon events, and keep `maybeNotify`: + +```tsx + if (evt.evt === "event") { + const rec = evt.data.record; + setEvents((es) => [rec, ...es].slice(0, 1000)); + const w = wsOf(rec.surface_id); + if (w && w.id !== activeRef.current) void setWorkspaceMeta(w.id, { unread: true }); + void maybeNotify(rec.surface_id, rec.agent_label ?? "shell", rec.workspace_name, rec.kind); + void refresh(); + } else if (evt.evt === "events_read") { + const ids = new Set(evt.data.ids); + setEvents((es) => es.map((e) => (ids.has(e.id) ? { ...e, read: true } : e))); + } else if (evt.evt === "state") { + setStates((m) => ({ ...m, [evt.data.surface_id]: evt.data.state })); + void refresh(); + } else if (evt.evt === "exit") { + void refresh(); + } else { + void refresh(); + } +``` + +(Remove the previous `state`/`exit` feed-building blocks and the `feedId` ref. `maybeNotify`'s signature already takes `(surfaceId, agent, workspace, kind)` — `kind` is now the `EventRecord.kind` string, which is compatible.) + +5. Compute unread and pass everything down: + +```tsx + const unread = events.filter((e) => !e.read).length; +``` + +6. Update the renders: + +```tsx + setEventsOpen((v) => !v)} unread={unread} /> +``` + +```tsx + {eventsOpen && ( + { void markEventsRead({ target: "all" }); }} + onSelect={(sid, id) => { void focusSurface(sid); void markEventsRead({ target: "ids", value: [id] }); }} + /> + )} +``` + +- [ ] **Step 3: Type-check** + +Run: `cd app && npx tsc --noEmit` +Expected: errors only in `EventCenter.tsx`/`TopBar.tsx` (updated in Task 10) about prop mismatches — those are fixed next. App.tsx itself type-clean. + +- [ ] **Step 4: Commit** + +```bash +git add app/src/socketBridge.ts app/src/App.tsx +git commit -m "feat(app): source Event Center feed from the daemon event log" +``` + +--- + +## Task 10: EventCenter real filters + TopBar bell badge + +**Files:** +- Modify: `app/src/EventCenter.tsx` +- Modify: `app/src/TopBar.tsx` + +- [ ] **Step 1: Rework EventCenter to consume daemon records** + +Replace the `EventCenter` props and feed logic in `app/src/EventCenter.tsx`. Remove the local `FeedEntry` interface (the daemon `EventRecord` replaces it) and the `feedId`-based shape: + +```tsx +import { useState } from "react"; +import { Check, Hourglass, X, CircleDot, Power, Send, MessageSquare } from "lucide-react"; +import { COLORS, FONT } from "./theme"; +import type { EventRecord } from "./socketBridge"; + +const ICON: Record = { + done: , wait: , error: , exit: , +}; +const COLOR: Record = { + done: COLORS.stDone, wait: COLORS.stWait, error: COLORS.stError, exit: COLORS.textMuted, +}; + +type Tab = "all" | "unread" | "errors"; +const TABS: { id: Tab; label: string }[] = [ + { id: "all", label: "All" }, + { id: "unread", label: "Unread" }, + { id: "errors", label: "Errors" }, +]; + +function rel(ts: number): string { + const s = Math.max(0, Math.floor((Date.now() - ts) / 1000)); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h`; + return `${Math.floor(s / 86400)}d`; +} + +export function EventCenter({ + events, onMarkAllRead, onSelect, +}: { + events: EventRecord[]; + onMarkAllRead: () => void; + onSelect: (surfaceId: string, id: number) => void; +}) { + const [tab, setTab] = useState("all"); + const shown = tab === "unread" ? events.filter((e) => !e.read) + : tab === "errors" ? events.filter((e) => e.kind === "error") + : events; + + return ( +
+
+ Event Center + Mark all read +
+ +
+ {TABS.map((t) => { + const on = t.id === tab; + return ( + + ); + })} +
+ +
+ {shown.length === 0 &&
No events yet.
} + {shown.map((e) => ( +
onSelect(e.surface_id, e.id)} + style={{ display: "flex", gap: 9, padding: 10, borderRadius: 8, border: `1px solid ${COLORS.borderSubtle}`, cursor: "pointer", opacity: e.read ? 0.55 : 1 }}> + {ICON[e.kind]} +
+
{e.workspace_name} · {e.agent_label ?? "shell"}
+
{e.kind} {rel(e.ts)}
+
+ {!e.read && } +
+ ))} +
+ + {/* External notification channels — mocked until the daemon subscriber lands (SP5). */} +
+ EXTERNAL NOTIFY +
+ {[ + { name: "Telegram", icon: }, + { name: "MAX", icon: }, + ].map((c) => ( +
+ {c.icon} + {c.name} + +
+ ))} +
+
+
+ ); +} +``` + +(The old `FeedEntry` export is gone. Any remaining `import { ... FeedEntry } from "./EventCenter"` in `App.tsx` must be removed — it was replaced by `EventRecord` in Task 9.) + +- [ ] **Step 2: Add the bell badge to TopBar** + +In `app/src/TopBar.tsx`, extend the props and render a numeric badge on the bell. Change the signature: + +```tsx +export function TopBar({ + active, eventsOpen, onToggleEvents, unread, +}: { + active: WorkspaceView | null; + eventsOpen: boolean; + onToggleEvents: () => void; + unread: number; +}) { +``` + +Replace the bell `IconBtn` with a wrapper that overlays a badge: + +```tsx +
+ } title="Notifications (mock)" /> + {unread > 0 && ( + + {unread > 99 ? "99+" : unread} + + )} +
+``` + +- [ ] **Step 3: Build the frontend** + +Run: `cd app && npm run build` +Expected: `tsc` clean, `vite build` succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add app/src/EventCenter.tsx app/src/TopBar.tsx +git commit -m "feat(app): real Unread/Errors filters and bell unread badge" +``` + +--- + +## Task 11: Manual scenario + full verification + +**Files:** +- Modify: `DOCS/RUNNING.md` (the M3 status section + known-limitations) + +- [ ] **Step 1: Run the entire automated suite** + +Run: `cargo test --workspace` +Expected: PASS (proto + daemon). + +Run: `cd app && npm run build` +Expected: clean. + +- [ ] **Step 2: Document the manual scenario** + +In `DOCS/RUNNING.md`, add an SP2 subsection under the M3 status area: + +```markdown +### SP2 — Persistent event log / read-model +1. Drive a panel to `done`/`error` (or `spacesh notify --surface --state error`). An entry appears in the Event Center; the `bell` badge increments. +2. **Restart the GUI** (daemon stays up): the feed is intact (served from the daemon, not GUI memory). +3. **Cold-restart the daemon** (`spacesh shutdown`, reopen): the feed is *still* intact — restored from `~/.spacesh/events.json`. +4. Click an entry (or focus its panel): the entry dims, the badge decrements. `Mark all read` clears the badge. +5. Tabs filter live: `Unread` = not-yet-read only, `Errors` = `error` events only. + +Log file: `~/.spacesh/events.json` (ring of 1000, atomic write + corrupt-backup like `state.json`). +``` + +Update the known-limitations bullet about the Event Center (it currently says the feed lives in GUI memory and tabs are unfiltered) to reflect that the feed is now daemon-owned, persisted, and the tabs/badge are real; note that Telegram/MAX channels remain mocked (SP5). + +- [ ] **Step 3: Commit** + +```bash +git add DOCS/RUNNING.md +git commit -m "docs: SP2 manual test scenario and updated limitations" +``` + +- [ ] **Step 4: Final review handoff** + +Dispatch a final code review across the SP2 commits before merging the branch. + +--- + +## Notes for the implementer + +- **Branch first.** This plan should run on a `spacesh-sp2` branch, not `main` (the repo default). Create it before Task 1. +- **The frontend parity work is uncommitted** on the working tree (TopBar/LayoutEngine/Sidebar/etc. + `package.json`). Tasks 9–10 build on those files. Either commit that parity work first (recommended) or ensure it is present before starting the GUI tasks, so diffs apply cleanly. +- **TDD discipline:** every Rust task writes the test first, watches it fail, then implements. The GUI tasks are verified by `tsc`/`vite build` and the manual scenario. +- **Performance:** event recording is O(1) and persistence is debounced (500 ms) exactly like the structure snapshot; this never touches the PTY keypress/render path.