807eab3f6c
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1443 lines
52 KiB
Markdown
1443 lines
52 KiB
Markdown
# 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 6–7 consume them; remove them in Task 6.)
|
||
|
||
- [ ] **Step 5: Fix the existing integration-test call sites**
|
||
|
||
The in-crate tests call `serve(...)`. Find them:
|
||
|
||
Run: `grep -rn "serve(" crates/spaceshd/src/server.rs`
|
||
|
||
For each test that calls `server::serve(&path, store)` (or `serve(&path, store)`), add an in-memory event store argument. Use the same `JsonEventStore` pointed at a temp path (tests already create temp dirs). Concretely, where a test builds `store`, add:
|
||
|
||
```rust
|
||
let event_store: std::sync::Arc<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(®, &mut event_log, &event_persister, &clients,
|
||
&surface_id, spacesh_proto::event::EventKind::Exit);
|
||
let evt = Envelope::Evt(Evt::Exit { surface_id: surface_id.clone(), code });
|
||
broadcast_evt(&clients, &evt);
|
||
}
|
||
ServerMsg::StateDetected { surface_id, state } => {
|
||
if reg.is_running(&surface_id) {
|
||
reg.set_state(&surface_id, state);
|
||
broadcast_evt(&clients, &Envelope::Evt(Evt::State { surface_id: surface_id.clone(), state }));
|
||
if let Some(kind) = kind_for_state(state) {
|
||
record_event(®, &mut event_log, &event_persister, &clients, &surface_id, kind);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Note: `Evt::State` now clones `surface_id` because we use it again for recording.
|
||
|
||
- [ ] **Step 3: Write the failing integration test**
|
||
|
||
Add to the `tests` module in `server.rs`. This drives a state transition and asserts an `Evt::Event` is broadcast. Model it on the existing socket integration tests (which use `req`/connect helpers and `crate::test_support::serial()`):
|
||
|
||
```rust
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||
async fn done_state_emits_event_record() {
|
||
let _serial = crate::test_support::serial();
|
||
let (path, mut s, ws_id) = setup_with_workspace().await; // helper below
|
||
// Create a shell surface.
|
||
let r = req(&mut s, 1, Cmd::NewSurface { workspace_id: ws_id.clone(), command: None, args: vec![], cols: 80, rows: 24 }).await;
|
||
let sid = r["surface_id"].as_str().unwrap().to_string();
|
||
|
||
// Drive a Done state via the SetState command path.
|
||
let _ = req(&mut s, 2, Cmd::SetState { surface_id: SurfaceId(sid.clone()), state: SurfaceState::Done }).await;
|
||
|
||
// The daemon should broadcast an Evt::Event for this surface.
|
||
let evt = next_evt_matching(&mut s, |e| matches!(e, Evt::Event { record } if record.surface_id.0 == sid)).await;
|
||
if let Evt::Event { record } = evt {
|
||
assert_eq!(record.kind, spacesh_proto::event::EventKind::Done);
|
||
assert!(!record.read);
|
||
} else { panic!("expected Evt::Event"); }
|
||
|
||
cleanup(path).await;
|
||
}
|
||
```
|
||
|
||
**Implementer note:** `setup_with_workspace`, `req`, `next_evt_matching`, and `cleanup` are test scaffolding. If equivalents already exist in the `server.rs` test module, reuse them and adjust names. If not, add minimal helpers next to the existing connect/`req` helpers: `setup_with_workspace` connects a client, sends `Cmd::Open { path: temp }`, returns `(temp_path, stream, workspace_id)`; `next_evt_matching` reads frames until an `Envelope::Evt` matches the predicate (skipping `res`/other evts), with a `tokio::time::timeout` of 2s that panics on elapse. Verify the daemon's `SetState` command actually routes through `StateDetected` (it goes through the state channel); if `SetState` short-circuits elsewhere, drive the state by sending `Cmd::SetState` and confirm via the existing state-event test's mechanism.
|
||
|
||
- [ ] **Step 4: Run the test**
|
||
|
||
Run: `cargo test -p spaceshd done_state_emits_event_record -- --nocapture`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Run the full daemon suite**
|
||
|
||
Run: `cargo test -p spaceshd`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add crates/spaceshd/src/server.rs
|
||
git commit -m "feat(daemon): record done/wait/error/exit into the event log"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: EventLog / MarkRead commands + Focus marks read
|
||
|
||
**Files:**
|
||
- Modify: `crates/spaceshd/src/server.rs` — `handle_request` signature + call site (`:159-161`), the `Cmd::Focus` arm (`:494`), and add `Cmd::EventLog`/`Cmd::MarkRead` arms before `Cmd::Status` (`:524`).
|
||
|
||
- [ ] **Step 1: Pass the log + persister into `handle_request`**
|
||
|
||
Update the call site in `router` (the `ServerMsg::Request` arm):
|
||
|
||
```rust
|
||
ServerMsg::Request { id, cmd, client, out } => {
|
||
handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients,
|
||
&router_tx, &exit_tx, &state_tx, &persister,
|
||
&mut event_log, &event_persister).await;
|
||
}
|
||
```
|
||
|
||
Extend the `handle_request` signature (after `persister: &Persister,`):
|
||
|
||
```rust
|
||
persister: &Persister,
|
||
event_log: &mut EventLog,
|
||
event_persister: &EventPersister,
|
||
) {
|
||
```
|
||
|
||
- [ ] **Step 2: Make Focus mark the surface's events read**
|
||
|
||
Replace the existing no-op `Cmd::Focus` arm:
|
||
|
||
```rust
|
||
Cmd::Focus { surface_id } => {
|
||
let ids = event_log.mark_read(&spacesh_proto::event::MarkReadTarget::Surface(surface_id.clone()));
|
||
if !ids.is_empty() {
|
||
event_persister.mark_dirty(event_log.snapshot());
|
||
broadcast_evt(clients, &Envelope::Evt(Evt::EventsRead { ids }));
|
||
}
|
||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add the EventLog and MarkRead arms**
|
||
|
||
Immediately before `Cmd::Status => {`:
|
||
|
||
```rust
|
||
Cmd::EventLog { limit } => {
|
||
let events = event_log.recent(limit);
|
||
let unread = event_log.unread_count();
|
||
let _ = out.send(ok(id, serde_json::json!({ "events": events, "unread": unread }))).await;
|
||
}
|
||
|
||
Cmd::MarkRead { target } => {
|
||
let ids = event_log.mark_read(&target);
|
||
if !ids.is_empty() {
|
||
event_persister.mark_dirty(event_log.snapshot());
|
||
broadcast_evt(clients, &Envelope::Evt(Evt::EventsRead { ids }));
|
||
}
|
||
let _ = out.send(ok(id, serde_json::Value::Null)).await;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Write the failing integration test**
|
||
|
||
Add to the `server.rs` tests module:
|
||
|
||
```rust
|
||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||
async fn event_log_query_and_mark_read() {
|
||
let _serial = crate::test_support::serial();
|
||
let (path, mut s, ws_id) = setup_with_workspace().await;
|
||
let r = req(&mut s, 1, Cmd::NewSurface { workspace_id: ws_id.clone(), command: None, args: vec![], cols: 80, rows: 24 }).await;
|
||
let sid = r["surface_id"].as_str().unwrap().to_string();
|
||
let _ = req(&mut s, 2, Cmd::SetState { surface_id: SurfaceId(sid.clone()), state: SurfaceState::Error }).await;
|
||
// Drain the broadcast Evt::Event so the next reads are deterministic.
|
||
let _ = next_evt_matching(&mut s, |e| matches!(e, Evt::Event { .. })).await;
|
||
|
||
// Query the log.
|
||
let log = req(&mut s, 3, Cmd::EventLog { limit: None }).await;
|
||
assert_eq!(log["unread"].as_u64().unwrap(), 1);
|
||
let first_id = log["events"][0]["id"].as_u64().unwrap();
|
||
|
||
// Mark it read by id.
|
||
let _ = req(&mut s, 4, Cmd::MarkRead { target: spacesh_proto::event::MarkReadTarget::Ids(vec![first_id]) }).await;
|
||
let read_evt = next_evt_matching(&mut s, |e| matches!(e, Evt::EventsRead { .. })).await;
|
||
if let Evt::EventsRead { ids } = read_evt { assert_eq!(ids, vec![first_id]); } else { panic!(); }
|
||
|
||
let log = req(&mut s, 5, Cmd::EventLog { limit: None }).await;
|
||
assert_eq!(log["unread"].as_u64().unwrap(), 0);
|
||
|
||
cleanup(path).await;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run the tests**
|
||
|
||
Run: `cargo test -p spaceshd event_log_query_and_mark_read -- --nocapture`
|
||
Expected: PASS.
|
||
|
||
Run: `cargo test -p spaceshd`
|
||
Expected: PASS (whole daemon suite).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add crates/spaceshd/src/server.rs
|
||
git commit -m "feat(daemon): EventLog/MarkRead commands; Focus marks surface read"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Tauri bridge commands
|
||
|
||
**Files:**
|
||
- Modify: `app/src-tauri/src/bridge.rs` (after the `status` command, ~`:230`)
|
||
- Modify: `app/src-tauri/src/lib.rs:30-50` (handler registration)
|
||
|
||
- [ ] **Step 1: Add the two commands**
|
||
|
||
In `app/src-tauri/src/bridge.rs`, after the `status` command, add:
|
||
|
||
```rust
|
||
#[tauri::command]
|
||
pub async fn event_log(state: BridgeState<'_>, limit: Option<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 9–10 build on those files. Either commit that parity work first (recommended) or ensure it is present before starting the GUI tasks, so diffs apply cleanly.
|
||
- **TDD discipline:** every Rust task writes the test first, watches it fail, then implements. The GUI tasks are verified by `tsc`/`vite build` and the manual scenario.
|
||
- **Performance:** event recording is O(1) and persistence is debounced (500 ms) exactly like the structure snapshot; this never touches the PTY keypress/render path.
|