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

52 KiB
Raw Blame History

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.rsEventKind, EventRecord, MarkReadTarget + serde tests.
  • crates/spaceshd/src/event_log.rsEventLog model + EventLogState (serializable form). No I/O, fully unit-testable.
  • crates/spaceshd/src/event_store.rsEventStore 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.rsevent_log + mark_read Tauri commands.
  • app/src-tauri/src/lib.rs — register the two new commands.
  • app/src/socketBridge.tsEventRecord 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:

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:

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
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:

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:

    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:

    Event { record: EventRecord },
    EventsRead { ids: Vec<u64> },
  • Step 4: Write the failing tests

Append to the tests module in message.rs:

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

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:

mod event_log;
  • Step 3: Run the tests

Run: cargo test -p spaceshd event_log:: Expected: PASS (5 tests).

  • Step 4: Commit
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):

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:

mod event_store;
  • Step 3: Run the tests

Run: cargo test -p spaceshd event_store:: Expected: PASS (4 tests).

  • Step 4: Commit
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:

    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:

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:

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:

    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);:

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:

        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
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:

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:

            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()):

    #[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
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.rshandle_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):

            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,):

    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:

        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 => {:

        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:

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

#[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,):

            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
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:

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:

  | { 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:
  const [events, setEvents] = useState<EventRecord[]>([]);
  1. In refresh (or a dedicated effect), seed the log once on connect:
  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.

  1. In the onDaemonEvent handler, replace the old feed-building for state/exit with handlers for the new daemon events, and keep maybeNotify:
      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.)

  1. Compute unread and pass everything down:
  const unread = events.filter((e) => !e.read).length;
  1. Update the renders:
      <TopBar active={active} eventsOpen={eventsOpen} onToggleEvents={() => setEventsOpen((v) => !v)} unread={unread} />
        {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
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:

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:

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:

        <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
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:

### 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
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.