Files
spaceshell/DOCS/superpowers/plans/2026-06-10-spacesh-sp2-event-log.md
2026-06-10 06:40:15 +07:00

1443 lines
52 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<String>,
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<u64>),
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<u32>,
},
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<u64> },
```
- [ ] **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<EventRecord>,
}
/// In-memory event log: a capped ring with monotonic ids.
pub struct EventLog {
records: VecDeque<EventRecord>,
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<EventRecord> = 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<String>,
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<u64> {
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<u32>) -> Vec<EventRecord> {
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<u64> = 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<u64> = 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<EventLogState>;
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<EventLogState> {
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::<EventLogState>(&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<EventLogState>,
}
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<dyn EventStore>, debounce: Duration) -> EventPersister {
let (tx, mut rx) = mpsc::channel::<EventLogState>(64);
tokio::spawn(async move {
let mut latest: Option<EventLogState> = None;
let mut deadline: Option<Instant> = 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<EventLogState> { 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<dyn state_store::StateStore> =
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<dyn event_store::EventStore> =
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<dyn StateStore>, event_store: Arc<dyn EventStore>) -> 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<ServerMsg>,
router_tx: mpsc::Sender<ServerMsg>,
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 67 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<dyn crate::event_store::EventStore> =
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<spacesh_proto::event::EventKind> {
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<ClientId, ClientTx>,
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(&reg, &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(&reg, &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<u32>) -> Result<Value, String> {
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<Value, String> {
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<void> {
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<EventRecord[]>([]);
```
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
<TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} unread={unread} />
```
```tsx
{eventsOpen && (
<EventCenter
events={events}
onMarkAllRead={() => { 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<string, React.ReactNode> = {
done: <Check size={13} />, wait: <Hourglass size={13} />, error: <X size={13} />, exit: <Power size={13} />,
};
const COLOR: Record<string, string> = {
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<Tab>("all");
const shown = tab === "unread" ? events.filter((e) => !e.read)
: tab === "errors" ? events.filter((e) => e.kind === "error")
: events;
return (
<div style={{ display: "flex", flexDirection: "column", width: 300, flex: "0 0 300px", background: COLORS.bgSidebar, height: "100%", padding: 14, boxSizing: "border-box", borderLeft: `1px solid ${COLORS.borderSubtle}` }}>
<div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}>
<span style={{ fontFamily: FONT.ui, fontSize: 13, fontWeight: 700, color: COLORS.textPrimary, flex: 1 }}>Event Center</span>
<span onClick={onMarkAllRead} style={{ fontFamily: FONT.ui, fontSize: 11, color: COLORS.accent, cursor: "pointer" }}>Mark all read</span>
</div>
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
{TABS.map((t) => {
const on = t.id === tab;
return (
<button key={t.id} onClick={() => setTab(t.id)}
style={{
height: 22, padding: "0 9px", borderRadius: 11, fontFamily: FONT.ui, fontSize: 11, fontWeight: on ? 600 : 400,
background: on ? COLORS.bgElevated : "transparent",
border: `1px solid ${on ? COLORS.borderStrong : "transparent"}`,
color: on ? COLORS.textPrimary : COLORS.textMuted,
}}>
{t.label}
</button>
);
})}
</div>
<div style={{ flex: 1, overflowY: "auto", display: "flex", flexDirection: "column", gap: 8, minHeight: 0 }}>
{shown.length === 0 && <div style={{ color: COLORS.textMuted, fontSize: 12 }}>No events yet.</div>}
{shown.map((e) => (
<div key={e.id} onClick={() => 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 }}>
<span style={{ color: COLOR[e.kind], display: "flex", alignItems: "center" }}>{ICON[e.kind]}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: FONT.mono, fontSize: 11, color: COLORS.textSecondary, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{e.workspace_name} · {e.agent_label ?? "shell"}</div>
<div style={{ fontFamily: FONT.ui, fontSize: 12, color: COLORS.textPrimary }}>{e.kind} <span style={{ color: COLORS.textMuted }}>{rel(e.ts)}</span></div>
</div>
{!e.read && <span style={{ width: 7, height: 7, borderRadius: "50%", background: COLORS.accent, alignSelf: "center", flex: "0 0 7px" }} />}
</div>
))}
</div>
{/* External notification channels — mocked until the daemon subscriber lands (SP5). */}
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 10, paddingTop: 10, borderTop: `1px solid ${COLORS.borderSubtle}` }}>
<span style={{ fontFamily: FONT.ui, fontSize: 10, fontWeight: 700, letterSpacing: 0.5, color: COLORS.textMuted }}>EXTERNAL NOTIFY</span>
<div style={{ display: "flex", gap: 8 }}>
{[
{ name: "Telegram", icon: <Send size={13} /> },
{ name: "MAX", icon: <MessageSquare size={13} /> },
].map((c) => (
<div key={c.name} style={{ display: "flex", alignItems: "center", gap: 7, flex: 1, height: 30, padding: "0 10px", borderRadius: 7, background: COLORS.bgPanel }}>
<span style={{ color: COLORS.textMuted, display: "flex" }}>{c.icon}</span>
<span style={{ fontFamily: FONT.ui, fontSize: 12, color: COLORS.textSecondary, flex: 1 }}>{c.name}</span>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: COLORS.textMuted }} />
</div>
))}
</div>
</div>
</div>
);
}
```
(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
<div style={{ position: "relative", display: "flex" }}>
<IconBtn icon={<Bell size={16} />} title="Notifications (mock)" />
{unread > 0 && (
<span style={{
position: "absolute", top: -2, right: -2, minWidth: 14, height: 14, padding: "0 3px",
borderRadius: 7, background: COLORS.stError, color: "#fff",
fontFamily: FONT.ui, fontSize: 9, fontWeight: 700,
display: "flex", alignItems: "center", justifyContent: "center", boxSizing: "border-box",
}}>
{unread > 99 ? "99+" : unread}
</span>
)}
</div>
```
- [ ] **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 <s> --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 910 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.