# spacesh M2 Implementation Plan — layouts & workspaces > **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:** Add n-ary split layouts, multi-workspace structure with UX-metadata (colored groups, unread, custom order), 10 layout presets + wizard, and disk persistence (`state.json`) with cold-start structure restore (panels come back `stopped`). **Architecture:** Layout/workspace **data types live in `spacesh-proto`** (they cross the wire in `status` / `layout_changed`); the **tree algorithms + preset generators live in `spacesh-core`** (now depends on `spacesh-proto`). The daemon owns the authoritative tree and persists it behind a `StateStore` trait (atomic `state.json`, debounced). The Tauri app renders the tree (new `LayoutEngine`) and sidebar, sending commands; no GUI-local layout state. **Tech Stack:** Rust (serde, tokio), React/TypeScript + Tauri 2. Builds on the shipped M0+M1 crates (`spacesh-proto`, `spacesh-pty`, `spacesh-core`, `spaceshd`) and `app/`. **Spec:** `DOCS/superpowers/specs/2026-06-09-spacesh-m2-design.md`. Base: `DOCS/MAIN.md` §8. **Conventions:** English code/comments. camelCase vars/fns, PascalCase types, snake_case files, UPPER_CASE env. `cargo test --workspace` is the Definition of Done and must stay green & non-flaky (heavy socket/PTY integration tests use `#[tokio::test(flavor = "multi_thread", worker_threads = 2)]` + the existing `crate::test_support::serial()` guard — apply both to any new such test). Commit after each task; append: `Co-Authored-By: Claude Opus 4.8 (1M context) `. Do not `git push`. --- ## File Structure ``` crates/spacesh-proto/src/ ids.rs # + GroupId newtype layout.rs (new) # Orient, LayoutNode (external-tagged serde) workspace.rs (new) # SurfaceSpec, Group, Workspace, SurfaceView, WorkspaceView message.rs # + new Cmd / Evt variants lib.rs # + re-exports crates/spacesh-core/src/ Cargo.toml # + spacesh-proto path dep ops.rs (new) # tree algorithms on proto::LayoutNode (insert/remove/ratios/move/find) presets.rs (new) # 10 preset -> LayoutNode generators lib.rs # + re-exports crates/spaceshd/src/ state_store.rs (new) # StateStore trait + JsonStateStore (atomic, corrupt-backup) persist.rs (new) # debounce scheduler registry.rs # rewritten: Workspace/Group structure + running/stopped surfaces surface.rs # + restart support (spec carried for stopped panels) server.rs # + new command dispatch, new events, cold-start restore app/src/ layoutTypes.ts (new) # TS mirror of LayoutNode/Workspace/Group socketBridge.ts # + new commands/events LayoutEngine.tsx (new) # recursive tree render + splitter resize + stopped overlay Sidebar.tsx (new) # groups/workspaces/unread/drag-reorder PresetPicker.tsx (new) # 10 preset thumbnails Wizard.tsx (new) # folder -> preset -> agent-per-slot -> apply_preset App.tsx # rewired around workspaces + LayoutEngine ``` --- ## Phase 1 — proto: layout & workspace data types ### Task 1: GroupId + Orient + LayoutNode **Files:** - Modify: `crates/spacesh-proto/src/ids.rs` - Create: `crates/spacesh-proto/src/layout.rs` - Modify: `crates/spacesh-proto/src/lib.rs` - [ ] **Step 1: Add GroupId to ids.rs** Append to `crates/spacesh-proto/src/ids.rs`: ```rust #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct GroupId(pub String); impl std::fmt::Display for GroupId { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } } ``` - [ ] **Step 2: Write the failing test for LayoutNode serde** Create `crates/spacesh-proto/src/layout.rs`: ```rust use serde::{Deserialize, Serialize}; use crate::ids::SurfaceId; /// Split orientation. `H` lays children left-to-right; `V` top-to-bottom. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Orient { H, V, } /// Recursive n-ary layout tree. Externally tagged so JSON reads /// `{ "leaf": { "surface_id": "s_1" } }` / `{ "split": { ... } }`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum LayoutNode { Leaf { surface_id: SurfaceId }, Split { orient: Orient, ratios: Vec, children: Vec, }, } impl LayoutNode { pub fn leaf(id: SurfaceId) -> Self { LayoutNode::Leaf { surface_id: id } } } #[cfg(test)] mod tests { use super::*; #[test] fn leaf_serializes_externally_tagged() { let n = LayoutNode::leaf(SurfaceId("s_1".into())); let j = serde_json::to_string(&n).unwrap(); assert_eq!(j, r#"{"leaf":{"surface_id":"s_1"}}"#); } #[test] fn split_round_trips() { let n = LayoutNode::Split { orient: Orient::V, ratios: vec![0.5, 0.5], children: vec![ LayoutNode::leaf(SurfaceId("s_1".into())), LayoutNode::leaf(SurfaceId("s_2".into())), ], }; let j = serde_json::to_string(&n).unwrap(); assert!(j.contains(r#""split""#)); assert!(j.contains(r#""orient":"v""#)); let back: LayoutNode = serde_json::from_str(&j).unwrap(); assert_eq!(back, n); } } ``` - [ ] **Step 3: Wire the module** `crates/spacesh-proto/src/lib.rs` — add after the existing modules: ```rust pub mod codec; pub mod ids; pub mod layout; pub mod message; pub mod workspace; pub use ids::{GroupId, SurfaceId, WorkspaceId}; pub use layout::{LayoutNode, Orient}; pub use message::{Cmd, Envelope, ErrorBody, Evt}; pub use workspace::{Group, SurfaceSpec, SurfaceView, Workspace, WorkspaceView}; ``` (`workspace` is created in Task 2; if building Task 1 alone, temporarily omit the `workspace` mod + its re-export and restore in Task 2.) - [ ] **Step 4: Run tests** Run: `cargo test -p spacesh-proto layout` Expected: PASS (2 tests). - [ ] **Step 5: Commit** ```bash git add crates/spacesh-proto/src/ids.rs crates/spacesh-proto/src/layout.rs crates/spacesh-proto/src/lib.rs git commit -m "feat(proto): GroupId, Orient, n-ary LayoutNode with external-tagged serde" ``` --- ### Task 2: SurfaceSpec, Group, Workspace, view types **Files:** - Create: `crates/spacesh-proto/src/workspace.rs` - Modify: `crates/spacesh-proto/src/lib.rs` (enable `workspace` mod from Task 1 Step 3) - [ ] **Step 1: Write the failing test** Create `crates/spacesh-proto/src/workspace.rs`: ```rust use std::collections::HashMap; use serde::{Deserialize, Serialize}; use crate::ids::{GroupId, SurfaceId, WorkspaceId}; use crate::layout::LayoutNode; /// Everything needed to (re)create a panel's process. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SurfaceSpec { pub command: String, #[serde(default)] pub args: Vec, pub cwd: String, #[serde(default)] pub agent_label: Option, pub cols: u16, pub rows: u16, #[serde(default)] pub autostart: bool, } /// A colored, ordered collection of workspaces. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Group { pub id: GroupId, pub name: String, pub color: String, pub order: u32, } /// Persisted workspace: structure only (no live process state). #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Workspace { pub id: WorkspaceId, pub path: String, pub name: String, #[serde(default)] pub group_id: Option, pub order: u32, #[serde(default)] pub unread: bool, /// None = empty workspace (no panels yet). #[serde(default)] pub layout: Option, pub surfaces: HashMap, } /// Per-surface view in `status` — spec plus live lifecycle flag. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SurfaceView { pub spec: SurfaceSpec, /// true = has a live actor/PTY; false = stopped (in tree, no process). pub running: bool, } /// Workspace view in `status` / `workspace_changed`: structure + per-surface state. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct WorkspaceView { pub id: WorkspaceId, pub path: String, pub name: String, pub group_id: Option, pub order: u32, pub unread: bool, pub layout: Option, pub surfaces: HashMap, } #[cfg(test)] mod tests { use super::*; #[test] fn surface_spec_round_trips() { let s = SurfaceSpec { command: "claude".into(), args: vec![], cwd: "/tmp".into(), agent_label: Some("claude".into()), cols: 80, rows: 24, autostart: false, }; let j = serde_json::to_string(&s).unwrap(); let back: SurfaceSpec = serde_json::from_str(&j).unwrap(); assert_eq!(back, s); } #[test] fn workspace_round_trips_with_empty_layout() { let w = Workspace { id: WorkspaceId("w_1".into()), path: "/tmp/p".into(), name: "p".into(), group_id: None, order: 0, unread: false, layout: None, surfaces: HashMap::new(), }; let j = serde_json::to_string(&w).unwrap(); let back: Workspace = serde_json::from_str(&j).unwrap(); assert_eq!(back, w); } } ``` - [ ] **Step 2: Run tests** Run: `cargo test -p spacesh-proto workspace` Expected: PASS (2 tests). Ensure `lib.rs` has the `workspace` mod + re-exports from Task 1 Step 3. - [ ] **Step 3: Commit** ```bash git add crates/spacesh-proto/src/workspace.rs crates/spacesh-proto/src/lib.rs git commit -m "feat(proto): SurfaceSpec, Group, Workspace, status view types" ``` --- ## Phase 2 — proto: commands & events ### Task 3: New M2 command and event variants **Files:** - Modify: `crates/spacesh-proto/src/message.rs` - [ ] **Step 1: Write the failing test** Add to the `tests` module in `crates/spacesh-proto/src/message.rs`: ```rust #[test] fn split_surface_serializes() { let env = Envelope::Req { id: 1, cmd: Cmd::SplitSurface { surface_id: SurfaceId("s_1".into()), dir: SplitDir::Right, command: None, args: vec![], }, }; let j = serde_json::to_string(&env).unwrap(); assert!(j.contains("split_surface")); assert!(j.contains(r#""dir":"right""#)); let back: Envelope = serde_json::from_str(&j).unwrap(); assert_eq!(back, env); } #[test] fn apply_preset_round_trips() { let env = Envelope::Req { id: 2, cmd: Cmd::ApplyPreset { workspace_id: WorkspaceId("w_1".into()), preset_id: "2x2".into(), slots: vec![ PresetSlot { command: Some("claude".into()), args: vec![] }, PresetSlot { command: None, args: vec![] }, ], }, }; let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); assert_eq!(back, env); } #[test] fn set_ratios_round_trips() { let env = Envelope::Req { id: 3, cmd: Cmd::SetRatios { workspace_id: WorkspaceId("w_1".into()), node_path: vec![0, 1], ratios: vec![0.3, 0.7], }, }; let back: Envelope = serde_json::from_str(&serde_json::to_string(&env).unwrap()).unwrap(); assert_eq!(back, env); } #[test] fn layout_changed_event_round_trips() { let evt = Envelope::Evt(Evt::LayoutChanged { workspace_id: WorkspaceId("w_1".into()), layout: Some(crate::layout::LayoutNode::leaf(SurfaceId("s_1".into()))), }); let back: Envelope = serde_json::from_str(&serde_json::to_string(&evt).unwrap()).unwrap(); assert_eq!(back, evt); } ``` - [ ] **Step 2: Run test to verify it fails** Run: `cargo test -p spacesh-proto message` Expected: FAIL to compile (`Cmd::SplitSurface`, `SplitDir`, `PresetSlot`, `Evt::LayoutChanged` not defined). - [ ] **Step 3: Add the new types and variants** In `crates/spacesh-proto/src/message.rs`, update imports at the top: ```rust use serde::{Deserialize, Serialize}; use crate::ids::{GroupId, SurfaceId, WorkspaceId}; use crate::layout::LayoutNode; use crate::workspace::{Group, WorkspaceView}; ``` Add these helper types above the `Cmd` enum: ```rust /// Direction a split grows the new neighbor. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SplitDir { Right, Down, } /// Edge of a target leaf to drop a moved panel against. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Edge { Left, Right, Top, Bottom, } /// One panel slot when applying a preset. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PresetSlot { #[serde(default, skip_serializing_if = "Option::is_none")] pub command: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub args: Vec, } ``` Add these variants inside `enum Cmd` (before `Status`): ```rust SplitSurface { surface_id: SurfaceId, dir: SplitDir, #[serde(default, skip_serializing_if = "Option::is_none")] command: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] args: Vec, }, SetRatios { workspace_id: WorkspaceId, node_path: Vec, ratios: Vec }, MoveSurface { surface_id: SurfaceId, target_surface_id: SurfaceId, edge: Edge }, ApplyPreset { workspace_id: WorkspaceId, preset_id: String, slots: Vec }, RestartSurface { surface_id: SurfaceId }, CloseWorkspace { workspace_id: WorkspaceId }, SetWorkspaceMeta { workspace_id: WorkspaceId, #[serde(default, skip_serializing_if = "Option::is_none")] name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] group_id: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] unread: Option, #[serde(default, skip_serializing_if = "Option::is_none")] order: Option, }, CreateGroup { name: String, color: String }, SetGroup { group_id: GroupId, #[serde(default, skip_serializing_if = "Option::is_none")] name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] color: Option, #[serde(default, skip_serializing_if = "Option::is_none")] order: Option, }, DeleteGroup { group_id: GroupId }, ``` Note `group_id: Option>` on `SetWorkspaceMeta`: outer `None` = "don't change"; `Some(None)` = "ungroup"; `Some(Some(g))` = "move to group g". Add these variants inside `enum Evt`: ```rust LayoutChanged { workspace_id: WorkspaceId, layout: Option }, WorkspaceChanged { workspace: WorkspaceView }, WorkspaceClosed { workspace_id: WorkspaceId }, GroupsChanged { groups: Vec }, SurfaceRestarted { surface_id: SurfaceId }, ``` - [ ] **Step 4: Run tests** Run: `cargo test -p spacesh-proto` Expected: PASS (all proto tests incl. the 4 new ones). - [ ] **Step 5: Commit** ```bash git add crates/spacesh-proto/src/message.rs git commit -m "feat(proto): M2 commands (split/ratios/move/preset/restart/groups/meta) and events" ``` --- ## Phase 3 — core: tree algorithms & presets ### Task 4: spacesh-core depends on proto; tree ops module **Files:** - Modify: `crates/spacesh-core/Cargo.toml` - Create: `crates/spacesh-core/src/ops.rs` - Modify: `crates/spacesh-core/src/lib.rs` - [ ] **Step 1: Add the proto dependency** `crates/spacesh-core/Cargo.toml` — add to `[dependencies]`: ```toml spacesh-proto = { path = "../spacesh-proto" } ``` - [ ] **Step 2: Write the failing tests** Create `crates/spacesh-core/src/ops.rs`: ```rust //! Pure algorithms over `spacesh_proto::LayoutNode`. No I/O. use spacesh_proto::layout::{LayoutNode, Orient}; use spacesh_proto::ids::SurfaceId; /// Minimum ratio a panel may shrink to (5%). const MIN_RATIO: f32 = 0.05; /// Collect all surface ids in the tree, left-to-right. pub fn leaves(node: &LayoutNode) -> Vec { let mut out = Vec::new(); collect(node, &mut out); out } fn collect(node: &LayoutNode, out: &mut Vec) { match node { LayoutNode::Leaf { surface_id } => out.push(surface_id.clone()), LayoutNode::Split { children, .. } => children.iter().for_each(|c| collect(c, out)), } } /// Split the leaf `target` by inserting `new_id` as a sibling on `dir`. /// Returns true if the target was found and split. pub fn split_leaf(root: &mut LayoutNode, target: &SurfaceId, dir: Orient, after: bool, new_id: SurfaceId) -> bool { // If root itself is the target leaf, replace it with a split. if let LayoutNode::Leaf { surface_id } = root { if surface_id == target { let existing = root.clone(); let new_leaf = LayoutNode::leaf(new_id); let children = if after { vec![existing, new_leaf] } else { vec![new_leaf, existing] }; *root = LayoutNode::Split { orient: dir, ratios: even(children.len()), children }; return true; } return false; } if let LayoutNode::Split { orient, ratios, children } = root { // If a direct child is the target leaf AND this split matches `dir`, insert as sibling. if *orient == dir { if let Some(i) = children.iter().position(|c| is_leaf(c, target)) { children.insert(i + if after { 1 } else { 0 }, LayoutNode::leaf(new_id)); *ratios = even(children.len()); return true; } } // Otherwise recurse. for c in children.iter_mut() { if split_leaf(c, target, dir, after, new_id.clone()) { return true; } } } false } /// Remove the leaf `target`. Collapses empty/now-single-child splits and promotes /// single children. Returns the new root (None if the tree became empty). pub fn remove_leaf(root: LayoutNode, target: &SurfaceId) -> Option { match root { LayoutNode::Leaf { surface_id } => { if &surface_id == target { None } else { Some(LayoutNode::Leaf { surface_id }) } } LayoutNode::Split { orient, children, .. } => { let kept: Vec = children .into_iter() .filter_map(|c| remove_leaf(c, target)) .collect(); match kept.len() { 0 => None, 1 => Some(kept.into_iter().next().unwrap()), // promote single child n => Some(LayoutNode::Split { orient, ratios: even(n), children: kept }), } } } } /// Set ratios on the split node addressed by `path` (child indices from root). /// Normalizes to sum 1.0 and clamps each to >= MIN_RATIO. Returns false if the /// path is invalid or the length does not match the node's child count. pub fn set_ratios(root: &mut LayoutNode, path: &[u32], ratios: &[f32]) -> bool { let Some(node) = node_at_mut(root, path) else { return false }; if let LayoutNode::Split { ratios: r, children, .. } = node { if ratios.len() != children.len() { return false; } *r = normalize_clamp(ratios); true } else { false } } /// Move leaf `src` to sit on `edge` of leaf `target`. Returns the new root. /// No-op (returns the original) if src == target or either is missing. pub fn move_leaf(root: LayoutNode, src: &SurfaceId, target: &SurfaceId, edge: spacesh_proto::message::Edge) -> LayoutNode { use spacesh_proto::message::Edge; if src == target || !contains(&root, src) || !contains(&root, target) { return root; } let Some(removed) = remove_leaf(root, src) else { return LayoutNode::leaf(src.clone()) }; let (orient, after) = match edge { Edge::Left => (Orient::H, false), Edge::Right => (Orient::H, true), Edge::Top => (Orient::V, false), Edge::Bottom => (Orient::V, true), }; let mut root = removed; split_leaf(&mut root, target, orient, after, src.clone()); root } // ---- helpers ---- fn is_leaf(node: &LayoutNode, id: &SurfaceId) -> bool { matches!(node, LayoutNode::Leaf { surface_id } if surface_id == id) } fn contains(node: &LayoutNode, id: &SurfaceId) -> bool { leaves(node).iter().any(|s| s == id) } fn even(n: usize) -> Vec { vec![1.0 / n as f32; n] } fn normalize_clamp(ratios: &[f32]) -> Vec { let clamped: Vec = ratios.iter().map(|r| r.max(MIN_RATIO)).collect(); let sum: f32 = clamped.iter().sum(); clamped.iter().map(|r| r / sum).collect() } fn node_at_mut<'a>(root: &'a mut LayoutNode, path: &[u32]) -> Option<&'a mut LayoutNode> { let mut cur = root; for &idx in path { match cur { LayoutNode::Split { children, .. } => { cur = children.get_mut(idx as usize)?; } LayoutNode::Leaf { .. } => return None, } } Some(cur) } #[cfg(test)] mod tests { use super::*; fn sid(s: &str) -> SurfaceId { SurfaceId(s.into()) } #[test] fn split_root_leaf_creates_split() { let mut root = LayoutNode::leaf(sid("s_1")); assert!(split_leaf(&mut root, &sid("s_1"), Orient::H, true, sid("s_2"))); assert_eq!(leaves(&root), vec![sid("s_1"), sid("s_2")]); } #[test] fn split_same_orient_appends_as_sibling() { let mut root = LayoutNode::Split { orient: Orient::H, ratios: vec![0.5, 0.5], children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))], }; split_leaf(&mut root, &sid("s_2"), Orient::H, true, sid("s_3")); // 3 children in one row, even ratios. match &root { LayoutNode::Split { children, ratios, .. } => { assert_eq!(children.len(), 3); assert!((ratios.iter().sum::() - 1.0).abs() < 1e-5); } _ => panic!(), } assert_eq!(leaves(&root), vec![sid("s_1"), sid("s_2"), sid("s_3")]); } #[test] fn remove_promotes_single_child() { let root = LayoutNode::Split { orient: Orient::H, ratios: vec![0.5, 0.5], children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))], }; let after = remove_leaf(root, &sid("s_2")).unwrap(); assert_eq!(after, LayoutNode::leaf(sid("s_1"))); // split collapsed to the surviving leaf } #[test] fn remove_last_leaf_returns_none() { let root = LayoutNode::leaf(sid("s_1")); assert!(remove_leaf(root, &sid("s_1")).is_none()); } #[test] fn set_ratios_normalizes_and_clamps() { let mut root = LayoutNode::Split { orient: Orient::H, ratios: vec![0.5, 0.5], children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))], }; assert!(set_ratios(&mut root, &[], &[0.0, 1.0])); if let LayoutNode::Split { ratios, .. } = &root { assert!(ratios[0] >= MIN_RATIO); assert!((ratios.iter().sum::() - 1.0).abs() < 1e-5); } } #[test] fn set_ratios_wrong_len_rejected() { let mut root = LayoutNode::Split { orient: Orient::H, ratios: vec![0.5, 0.5], children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))], }; assert!(!set_ratios(&mut root, &[], &[1.0])); } #[test] fn move_leaf_to_right_of_target() { let root = LayoutNode::Split { orient: Orient::V, ratios: vec![0.5, 0.5], children: vec![LayoutNode::leaf(sid("s_1")), LayoutNode::leaf(sid("s_2"))], }; let after = move_leaf(root, &sid("s_1"), &sid("s_2"), spacesh_proto::message::Edge::Right); assert_eq!(leaves(&after), vec![sid("s_2"), sid("s_1")]); } #[test] fn move_onto_self_is_noop() { let root = LayoutNode::leaf(sid("s_1")); let after = move_leaf(root.clone(), &sid("s_1"), &sid("s_1"), spacesh_proto::message::Edge::Right); assert_eq!(after, root); } } ``` - [ ] **Step 3: Wire the module** `crates/spacesh-core/src/lib.rs`: ```rust pub mod grid; pub mod ops; pub mod presets; pub mod snapshot; pub use grid::GridSurface; pub use snapshot::Snapshot; ``` (`presets` is Task 5; omit its line + restore in Task 5 if building Task 4 alone.) - [ ] **Step 4: Run tests** Run: `cargo test -p spacesh-core ops` Expected: PASS (8 tests). - [ ] **Step 5: Commit** ```bash git add crates/spacesh-core/Cargo.toml crates/spacesh-core/src/ops.rs crates/spacesh-core/src/lib.rs git commit -m "feat(core): n-ary tree ops — split, remove+collapse, ratios, move" ``` --- ### Task 5: Preset generators **Files:** - Create: `crates/spacesh-core/src/presets.rs` - Modify: `crates/spacesh-core/src/lib.rs` (enable `presets` mod) - [ ] **Step 1: Write the failing test** Create `crates/spacesh-core/src/presets.rs`: ```rust //! The 10 layout presets (DOCS/MAIN.md §8.2). A preset maps a list of surface //! ids (one per slot, in order) to a LayoutNode. `slot_count` says how many //! panels the preset needs. use spacesh_proto::ids::SurfaceId; use spacesh_proto::layout::{LayoutNode, Orient}; /// Known preset ids and their panel counts. pub fn slot_count(preset_id: &str) -> Option { Some(match preset_id { "1" => 1, "2lr" => 2, // 2↔ "2tb" => 2, // 2↕ "2+1" => 3, "1+2" => 3, "3" => 3, "2x2" => 4, "4" => 4, // single row of 4 "2x3" => 6, "2x4" => 8, _ => return None, }) } fn leaf(id: &SurfaceId) -> LayoutNode { LayoutNode::leaf(id.clone()) } fn even(n: usize) -> Vec { vec![1.0 / n as f32; n] } fn row(ids: &[SurfaceId]) -> LayoutNode { LayoutNode::Split { orient: Orient::H, ratios: even(ids.len()), children: ids.iter().map(leaf).collect() } } fn col(children: Vec) -> LayoutNode { LayoutNode::Split { orient: Orient::V, ratios: even(children.len()), children } } fn rown(children: Vec) -> LayoutNode { LayoutNode::Split { orient: Orient::H, ratios: even(children.len()), children } } /// Build the preset tree from exactly `slot_count(preset_id)` ids. /// Returns None for an unknown id or wrong id count. pub fn build(preset_id: &str, ids: &[SurfaceId]) -> Option { if slot_count(preset_id)? != ids.len() { return None; } Some(match preset_id { "1" => leaf(&ids[0]), "2lr" => row(&ids), "2tb" => col(vec![leaf(&ids[0]), leaf(&ids[1])]), // left big column over... 2 stacked on the right. "2+1" => rown(vec![leaf(&ids[0]), col(vec![leaf(&ids[1]), leaf(&ids[2])])]), // one big on the left, 2 stacked on the right (mirror naming kept simple). "1+2" => rown(vec![col(vec![leaf(&ids[0]), leaf(&ids[1])]), leaf(&ids[2])]), "3" => row(&ids), "2x2" => col(vec![row(&ids[0..2]), row(&ids[2..4])]), "4" => row(&ids), "2x3" => col(vec![row(&ids[0..3]), row(&ids[3..6])]), "2x4" => col(vec![row(&ids[0..4]), row(&ids[4..8])]), _ => return None, }) } #[cfg(test)] mod tests { use super::*; use crate::ops::leaves; fn ids(n: usize) -> Vec { (0..n).map(|i| SurfaceId(format!("s_{i}"))).collect() } #[test] fn all_presets_have_counts() { for p in ["1","2lr","2tb","2+1","1+2","3","2x2","4","2x3","2x4"] { assert!(slot_count(p).is_some(), "missing count for {p}"); } assert!(slot_count("nope").is_none()); } #[test] fn build_uses_all_ids_in_order() { for p in ["1","2lr","2tb","2+1","1+2","3","2x2","4","2x3","2x4"] { let n = slot_count(p).unwrap(); let tree = build(p, &ids(n)).unwrap(); assert_eq!(leaves(&tree), ids(n), "preset {p} must place all ids in order"); } } #[test] fn build_rejects_wrong_id_count() { assert!(build("2x2", &ids(3)).is_none()); assert!(build("bogus", &ids(1)).is_none()); } #[test] fn grid_2x2_is_two_rows() { let tree = build("2x2", &ids(4)).unwrap(); match tree { LayoutNode::Split { orient: Orient::V, children, .. } => { assert_eq!(children.len(), 2); for r in &children { matches!(r, LayoutNode::Split { orient: Orient::H, .. }); } } _ => panic!("2x2 should be a vertical split of two horizontal rows"), } } } ``` - [ ] **Step 2: Run tests** Run: `cargo test -p spacesh-core presets` Expected: PASS (4 tests). - [ ] **Step 3: Commit** ```bash git add crates/spacesh-core/src/presets.rs crates/spacesh-core/src/lib.rs git commit -m "feat(core): 10 layout preset generators" ``` --- ## Phase 4 — daemon: persistence ### Task 6: StateStore trait + JsonStateStore **Files:** - Create: `crates/spaceshd/src/state_store.rs` - Modify: `crates/spaceshd/src/main.rs` (add `mod state_store;`) - [ ] **Step 1: Write the failing test** Create `crates/spaceshd/src/state_store.rs`: ```rust use std::path::{Path, PathBuf}; use anyhow::Result; use serde::{Deserialize, Serialize}; use spacesh_proto::workspace::{Group, Workspace}; /// The full persisted snapshot of structure (no live processes). #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct PersistState { pub version: u32, #[serde(default)] pub groups: Vec, #[serde(default)] pub workspaces: Vec, } pub trait StateStore: Send + Sync { fn load(&self) -> Result; fn save(&self, state: &PersistState) -> Result<()>; } /// JSON file store with atomic write (temp + rename) and corrupt-file backup. pub struct JsonStateStore { path: PathBuf, } impl JsonStateStore { 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 StateStore for JsonStateStore { fn load(&self) -> Result { if !self.path.exists() { return Ok(PersistState { version: 1, ..Default::default() }); } let bytes = std::fs::read(&self.path)?; match serde_json::from_slice::(&bytes) { Ok(state) => Ok(state), Err(_) => { // Corrupt file: back it up and start fresh. 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(PersistState { version: 1, ..Default::default() }) } } } fn save(&self, state: &PersistState) -> 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)?; // fsync the temp file before rename for durability. let f = std::fs::File::open(&tmp)?; f.sync_all()?; std::fs::rename(&tmp, &self.path)?; Ok(()) } } #[allow(dead_code)] fn touch(_p: &Path) {} #[cfg(test)] mod tests { use super::*; use spacesh_proto::ids::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-state-{name}-{n}.json")); p } fn sample() -> PersistState { PersistState { version: 1, groups: vec![], workspaces: vec![Workspace { id: WorkspaceId("w_1".into()), path: "/tmp/p".into(), name: "p".into(), group_id: None, order: 0, unread: false, layout: None, surfaces: std::collections::HashMap::new(), }], } } #[test] fn save_then_load_round_trips() { let path = tmp_file("roundtrip"); let store = JsonStateStore::new(path.clone()); store.save(&sample()).unwrap(); let back = store.load().unwrap(); assert_eq!(back, sample()); let _ = std::fs::remove_file(path); } #[test] fn missing_file_loads_default() { let store = JsonStateStore::new(tmp_file("missing")); let s = store.load().unwrap(); assert_eq!(s.version, 1); assert!(s.workspaces.is_empty()); } #[test] fn corrupt_file_is_backed_up_and_load_returns_default() { let path = tmp_file("corrupt"); std::fs::write(&path, b"{ this is not valid json").unwrap(); let store = JsonStateStore::new(path.clone()); let s = store.load().unwrap(); assert!(s.workspaces.is_empty()); // original path no longer holds the corrupt bytes (renamed away) assert!(!path.exists()); let _ = std::fs::remove_file(path); } } ``` - [ ] **Step 2: Wire the module** In `crates/spaceshd/src/main.rs`, add `mod state_store;` with the other `mod` lines. - [ ] **Step 3: Run tests** Run: `cargo test -p spaceshd state_store` Expected: PASS (3 tests). - [ ] **Step 4: Commit** ```bash git add crates/spaceshd/src/state_store.rs crates/spaceshd/src/main.rs git commit -m "feat(daemon): StateStore trait + atomic JSON store with corrupt-file backup" ``` --- ### Task 7: Debounced persist scheduler **Files:** - Create: `crates/spaceshd/src/persist.rs` - Modify: `crates/spaceshd/src/main.rs` (add `mod persist;`) - [ ] **Step 1: Write the failing test** Create `crates/spaceshd/src/persist.rs`: ```rust use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::sync::mpsc; use tokio::time::{Duration, Instant}; use crate::state_store::{PersistState, StateStore}; /// Debounce window: coalesce a burst of dirty signals into one save. const DEBOUNCE: Duration = Duration::from_millis(500); /// A handle the registry uses to request a persist. `mark_dirty(state)` records /// the latest snapshot and (re)arms the debounce timer. #[derive(Clone)] pub struct Persister { tx: mpsc::Sender, } impl Persister { pub fn mark_dirty(&self, state: PersistState) { // Best-effort; dropping a snapshot is fine because a newer one will arrive. let _ = self.tx.try_send(state); } } /// Spawn the debounce task. Returns the `Persister` handle. /// `debounce` is configurable so tests can use a short window. pub fn spawn(store: Arc, debounce: Duration) -> Persister { let (tx, mut rx) = mpsc::channel::(64); tokio::spawn(async move { let mut latest: Option = None; let mut deadline: Option = None; loop { let timer = async { match deadline { Some(d) => tokio::time::sleep_until(d).await, None => std::future::pending::<()>().await, } }; tokio::select! { msg = rx.recv() => { match msg { Some(state) => { latest = Some(state); deadline = Some(Instant::now() + debounce); } None => { // channel closed: final flush then exit if let Some(s) = latest.take() { let _ = store.save(&s); } break; } } } _ = timer => { if let Some(s) = latest.take() { let _ = store.save(&s); } deadline = None; } } } }); Persister { tx } } #[allow(dead_code)] fn _unused(_c: &AtomicUsize) {} #[cfg(test)] mod tests { use super::*; use std::sync::Mutex; struct CountingStore { saves: AtomicUsize, last: Mutex>, } impl StateStore for CountingStore { fn load(&self) -> anyhow::Result { Ok(PersistState::default()) } fn save(&self, state: &PersistState) -> anyhow::Result<()> { self.saves.fetch_add(1, Ordering::SeqCst); *self.last.lock().unwrap() = Some(state.clone()); Ok(()) } } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn burst_coalesces_to_one_save() { let store = Arc::new(CountingStore { saves: AtomicUsize::new(0), last: Mutex::new(None) }); let p = spawn(store.clone(), Duration::from_millis(80)); // Fire several dirty signals rapidly. for v in 1..=5u32 { let mut s = PersistState::default(); s.version = v; p.mark_dirty(s); tokio::time::sleep(Duration::from_millis(10)).await; } // Wait past the debounce window. tokio::time::sleep(Duration::from_millis(200)).await; assert_eq!(store.saves.load(Ordering::SeqCst), 1, "burst should coalesce to one save"); assert_eq!(store.last.lock().unwrap().as_ref().unwrap().version, 5, "save uses the latest snapshot"); } } ``` - [ ] **Step 2: Wire the module** In `crates/spaceshd/src/main.rs`, add `mod persist;`. - [ ] **Step 3: Run tests** Run: `cargo test -p spaceshd persist` Expected: PASS (1 test). - [ ] **Step 4: Commit** ```bash git add crates/spaceshd/src/persist.rs crates/spaceshd/src/main.rs git commit -m "feat(daemon): debounced persist scheduler coalescing bursts into one save" ``` --- ## Phase 5 — daemon: registry rewrite ### Task 8: Registry with workspaces, groups, layout trees, running/stopped **Files:** - Modify: `crates/spaceshd/src/registry.rs` (substantial rewrite) - Test: inline `#[cfg(test)]` in `registry.rs` This rewrite makes the registry own the full structure (`Workspace`/`Group` from proto) plus the live actor map keyed by `SurfaceId`. A surface is `running` iff a live `SurfaceHandle` exists for it; otherwise (present in some workspace's `surfaces` map) it is `stopped`. The registry produces both the `WorkspaceView` list for `status` and the `PersistState` for the store. - [ ] **Step 1: Write the new registry** Replace the entire contents of `crates/spaceshd/src/registry.rs`: ```rust use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use spacesh_proto::ids::{GroupId, SurfaceId, WorkspaceId}; use spacesh_proto::layout::LayoutNode; use spacesh_proto::workspace::{Group, SurfaceSpec, SurfaceView, Workspace, WorkspaceView}; use crate::state_store::PersistState; use crate::surface::SurfaceHandle; /// Single-threaded owner of structure (workspaces/groups/trees + per-surface /// specs) and the live actor map. Lives in the server router task. #[derive(Default)] pub struct Registry { counter: AtomicU64, groups: HashMap, workspaces: HashMap, by_path: HashMap, /// Live actors only. Absent id that exists in a workspace's `surfaces` = stopped. live: HashMap, } impl Registry { pub fn new() -> Self { Self::default() } fn next_id(&self, prefix: &str) -> String { let n = self.counter.fetch_add(1, Ordering::Relaxed); format!("{prefix}_{n:x}") } pub fn new_surface_id(&self) -> SurfaceId { SurfaceId(self.next_id("s")) } // ---- workspaces ---- /// Idempotent by canonicalized path. Returns (workspace_id, created?). pub fn open_workspace(&mut self, path: PathBuf) -> (WorkspaceId, bool) { let canonical = path.canonicalize().unwrap_or(path); let key = canonical.to_string_lossy().to_string(); if let Some(id) = self.by_path.get(&key) { return (id.clone(), false); } let id = WorkspaceId(self.next_id("w")); let name = canonical.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| key.clone()); let order = self.workspaces.len() as u32; self.workspaces.insert(id.clone(), Workspace { id: id.clone(), path: key.clone(), name, group_id: None, order, unread: false, layout: None, surfaces: HashMap::new(), }); self.by_path.insert(key, id.clone()); (id, true) } pub fn workspace(&self, id: &WorkspaceId) -> Option<&Workspace> { self.workspaces.get(id) } pub fn workspace_mut(&mut self, id: &WorkspaceId) -> Option<&mut Workspace> { self.workspaces.get_mut(id) } pub fn close_workspace(&mut self, id: &WorkspaceId) -> Vec { let Some(ws) = self.workspaces.remove(id) else { return vec![] }; self.by_path.retain(|_, v| v != id); let ids: Vec = ws.surfaces.keys().cloned().collect(); for sid in &ids { self.live.remove(sid); } ids } /// The workspace that owns a surface id, if any. pub fn workspace_of(&self, sid: &SurfaceId) -> Option { self.workspaces.values().find(|w| w.surfaces.contains_key(sid)).map(|w| w.id.clone()) } // ---- surfaces (structure) ---- pub fn add_surface_spec(&mut self, ws: &WorkspaceId, sid: SurfaceId, spec: SurfaceSpec) { if let Some(w) = self.workspaces.get_mut(ws) { w.surfaces.insert(sid, spec); } } pub fn surface_spec(&self, sid: &SurfaceId) -> Option { self.workspaces.values().find_map(|w| w.surfaces.get(sid).cloned()) } /// Remove a surface from its workspace's spec map and the tree. pub fn remove_surface(&mut self, sid: &SurfaceId) { self.live.remove(sid); if let Some(ws) = self.workspace_of(sid) { if let Some(w) = self.workspaces.get_mut(&ws) { w.surfaces.remove(sid); w.layout = w.layout.take().and_then(|l| spacesh_core::ops::remove_leaf(l, sid)); } } } // ---- live actors ---- pub fn set_live(&mut self, handle: SurfaceHandle) { self.live.insert(handle.id.clone(), handle); } pub fn live(&self, sid: &SurfaceId) -> Option<&SurfaceHandle> { self.live.get(sid) } pub fn mark_stopped(&mut self, sid: &SurfaceId) { self.live.remove(sid); } pub fn is_running(&self, sid: &SurfaceId) -> bool { self.live.contains_key(sid) } // ---- groups ---- pub fn create_group(&mut self, name: String, color: String) -> GroupId { let id = GroupId(self.next_id("g")); let order = self.groups.len() as u32; self.groups.insert(id.clone(), Group { id: id.clone(), name, color, order }); id } pub fn group_mut(&mut self, id: &GroupId) -> Option<&mut Group> { self.groups.get_mut(id) } pub fn delete_group(&mut self, id: &GroupId) { self.groups.remove(id); for w in self.workspaces.values_mut() { if w.group_id.as_ref() == Some(id) { w.group_id = None; } } } pub fn groups(&self) -> Vec { let mut g: Vec = self.groups.values().cloned().collect(); g.sort_by_key(|x| x.order); g } // ---- views & persistence ---- pub fn workspace_view(&self, id: &WorkspaceId) -> Option { let w = self.workspaces.get(id)?; Some(self.to_view(w)) } fn to_view(&self, w: &Workspace) -> WorkspaceView { let surfaces = w.surfaces.iter().map(|(sid, spec)| { (sid.clone(), SurfaceView { spec: spec.clone(), running: self.live.contains_key(sid) }) }).collect(); WorkspaceView { id: w.id.clone(), path: w.path.clone(), name: w.name.clone(), group_id: w.group_id.clone(), order: w.order, unread: w.unread, layout: w.layout.clone(), surfaces, } } pub fn status(&self) -> (Vec, Vec) { let mut ws: Vec = self.workspaces.values().map(|w| self.to_view(w)).collect(); ws.sort_by_key(|w| w.order); (self.groups(), ws) } pub fn persist_state(&self) -> PersistState { let mut workspaces: Vec = self.workspaces.values().cloned().collect(); workspaces.sort_by_key(|w| w.order); PersistState { version: 1, groups: self.groups(), workspaces } } /// Replace all structure from a loaded snapshot (cold start). Clears live map. pub fn restore(&mut self, state: PersistState) { self.groups = state.groups.into_iter().map(|g| (g.id.clone(), g)).collect(); self.workspaces.clear(); self.by_path.clear(); self.live.clear(); for w in state.workspaces { self.by_path.insert(w.path.clone(), w.id.clone()); self.workspaces.insert(w.id.clone(), w); } } } #[cfg(test)] mod tests { use super::*; use spacesh_proto::layout::{LayoutNode as LN, Orient}; fn spec() -> SurfaceSpec { SurfaceSpec { command: "/bin/sh".into(), args: vec![], cwd: "/tmp".into(), agent_label: None, cols: 80, rows: 24, autostart: false } } #[test] fn open_is_idempotent() { let mut r = Registry::new(); let (a, c1) = r.open_workspace(std::env::temp_dir()); let (b, c2) = r.open_workspace(std::env::temp_dir()); assert_eq!(a, b); assert!(c1 && !c2); } #[test] fn surface_running_then_stopped() { let mut r = Registry::new(); let (ws, _) = r.open_workspace(std::env::temp_dir()); let sid = r.new_surface_id(); r.add_surface_spec(&ws, sid.clone(), spec()); assert!(!r.is_running(&sid)); // spec present, no live actor = stopped let v = r.workspace_view(&ws).unwrap(); assert_eq!(v.surfaces.get(&sid).unwrap().running, false); } #[test] fn remove_surface_updates_tree() { let mut r = Registry::new(); let (ws, _) = r.open_workspace(std::env::temp_dir()); let s1 = r.new_surface_id(); let s2 = r.new_surface_id(); r.add_surface_spec(&ws, s1.clone(), spec()); r.add_surface_spec(&ws, s2.clone(), spec()); r.workspace_mut(&ws).unwrap().layout = Some(LN::Split { orient: Orient::H, ratios: vec![0.5, 0.5], children: vec![LN::leaf(s1.clone()), LN::leaf(s2.clone())], }); r.remove_surface(&s2); let w = r.workspace(&ws).unwrap(); assert!(!w.surfaces.contains_key(&s2)); assert_eq!(w.layout, Some(LN::leaf(s1))); // split collapsed } #[test] fn restore_round_trips_through_persist_state() { let mut r = Registry::new(); let (ws, _) = r.open_workspace(std::env::temp_dir()); r.add_surface_spec(&ws, r.new_surface_id(), spec()); let state = r.persist_state(); let mut r2 = Registry::new(); r2.restore(state.clone()); assert_eq!(r2.persist_state().workspaces.len(), state.workspaces.len()); } #[test] fn delete_group_ungroups_members() { let mut r = Registry::new(); let (ws, _) = r.open_workspace(std::env::temp_dir()); let g = r.create_group("prod".into(), "#fff".into()); r.workspace_mut(&ws).unwrap().group_id = Some(g.clone()); r.delete_group(&g); assert!(r.workspace(&ws).unwrap().group_id.is_none()); } } ``` - [ ] **Step 2: Note the API break** This rewrite changes `Registry`'s public API (`open_workspace` now returns `(WorkspaceId, bool)`; `insert_surface`/`surface`/`status` replaced). `server.rs` is updated in Task 10 to match; it will not compile until then. Build the crate's tests for this module in isolation: Run: `cargo test -p spaceshd registry 2>&1 | head -40` Expected: the `registry` module compiles and its tests pass **once `server.rs` is also updated** — so this task's green bar comes after Task 10. To check this module alone now, temporarily comment `mod server;` and the surface usages in `main.rs`, run `cargo test -p spaceshd registry`, then restore. (The implementer may instead implement Tasks 8–10 together and run tests once at the end of Task 10.) - [ ] **Step 3: Commit** ```bash git add crates/spaceshd/src/registry.rs git commit -m "feat(daemon): registry owns workspaces/groups/trees + running/stopped surfaces" ``` --- ## Phase 6 — daemon: surface restart ### Task 9: Surface restart support (stopped → running) **Files:** - Modify: `crates/spaceshd/src/surface.rs` The M0+M1 `spawn_surface` already builds an actor from a `PtyHandle`. M2 adds a helper to (re)spawn a surface actor from a `SurfaceSpec` so the server can restart `stopped` panels uniformly. No actor-internals change. - [ ] **Step 1: Add a spec-driven spawn helper** Add to `crates/spaceshd/src/surface.rs` (top-level, after imports): ```rust use spacesh_proto::workspace::SurfaceSpec; use spacesh_pty::SpawnSpec; /// Spawn (or restart) a surface actor from a persisted spec. Injects /// SPACESH_SURFACE_ID into the child env, mirroring `new_surface`. pub fn spawn_from_spec( id: SurfaceId, workspace_id: WorkspaceId, spec: &SurfaceSpec, exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>, ) -> std::io::Result { let pty = spacesh_pty::PtyHandle::spawn(SpawnSpec { command: spec.command.clone(), args: spec.args.clone(), cwd: std::path::PathBuf::from(&spec.cwd), cols: spec.cols, rows: spec.rows, env: vec![("SPACESH_SURFACE_ID".into(), id.0.clone())], }) .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?; Ok(spawn_surface(id, workspace_id, pty, spec.cols, spec.rows, exit_tx)) } ``` - [ ] **Step 2: Add a test** Add to the `tests` module in `surface.rs`: ```rust #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn spawn_from_spec_runs_the_command() { let _serial = crate::test_support::serial(); let spec = SurfaceSpec { command: "/bin/sh".into(), args: vec!["-c".into(), "printf RESPAWN; sleep 0.3".into()], cwd: std::env::temp_dir().to_string_lossy().into(), agent_label: None, cols: 80, rows: 24, autostart: false, }; let (exit_tx, _rx) = mpsc::unbounded_channel(); let handle = spawn_from_spec(SurfaceId("s_r".into()), WorkspaceId("w_1".into()), &spec, exit_tx).unwrap(); let (reply_tx, reply_rx) = oneshot::channel(); handle.tx.send(SurfaceMsg::Attach { reply: reply_tx }).await.unwrap(); let mut sub = reply_rx.await.unwrap(); let mut got = String::new(); let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2); while tokio::time::Instant::now() < deadline { if let Ok(Ok(b)) = tokio::time::timeout(tokio::time::Duration::from_millis(100), sub.recv()).await { got.push_str(&String::from_utf8_lossy(&b)); if got.contains("RESPAWN") { break; } } } assert!(got.contains("RESPAWN"), "got: {got:?}"); } ``` - [ ] **Step 3: Build note** Run: `cargo build -p spaceshd 2>&1 | head -20` Expected: compiles (the helper is additive). Tests run green together with Task 10 (server uses the helper). If building now, the `surface` module compiles independently. - [ ] **Step 4: Commit** ```bash git add crates/spaceshd/src/surface.rs git commit -m "feat(daemon): spawn_from_spec to (re)start surfaces from a persisted spec" ``` --- ## Phase 7 — daemon: server dispatch + restore ### Task 10: Wire new commands, events, and cold-start restore **Files:** - Modify: `crates/spaceshd/src/server.rs` (dispatch + restore + persist hook) - Modify: `crates/spaceshd/src/main.rs` (build the store + persister + restore on start) - Test: integration tests in `server.rs` This is the integration task. It updates `handle_request` for the new `Registry` API and adds the M2 commands; emits the new events; persists after every structural change; and restores structure on cold start. - [ ] **Step 1: Build the store and persister in the daemon entrypoint** In `crates/spaceshd/src/main.rs`, change `run_daemon` to construct the store + persister and pass them into `serve`. Replace `run_daemon`: ```rust async fn run_daemon() -> Result<()> { let Some(_lock) = lifecycle::acquire_instance_lock()? else { eprintln!("another spaceshd is already running"); return Ok(()); }; lifecycle::clear_stale_socket()?; let sock = lifecycle::socket_path()?; let state_path = lifecycle::spacesh_dir()?.join("state.json"); let store: std::sync::Arc = std::sync::Arc::new(state_store::JsonStateStore::new(state_path)); eprintln!("spaceshd listening on {}", sock.display()); server::serve(&sock, store).await } ``` - [ ] **Step 2: Update `serve` to accept the store, restore, and spawn the persister** In `crates/spaceshd/src/server.rs`, change the `serve` signature and head: ```rust use std::sync::Arc; use std::time::Duration; use crate::state_store::StateStore; use crate::persist::{self, Persister}; pub async fn serve(socket: &Path, store: Arc) -> Result<()> { let listener = UnixListener::bind(socket)?; let (router_tx, router_rx) = mpsc::channel::(256); let (exit_tx, mut exit_rx) = mpsc::unbounded_channel::<(SurfaceId, i32)>(); let router_for_exit = router_tx.clone(); tokio::spawn(async move { while let Some((sid, code)) = exit_rx.recv().await { let _ = router_for_exit.send(ServerMsg::Exit { surface_id: sid, code }).await; } }); let persister = persist::spawn(store.clone(), Duration::from_millis(500)); let initial = store.load().unwrap_or_default(); let shutdown = tokio::spawn(router(router_rx, router_tx.clone(), exit_tx, persister, initial)); let mut next_client: ClientId = 0; loop { let (stream, _addr) = listener.accept().await?; let client_id = next_client; next_client += 1; let router_tx = router_tx.clone(); tokio::spawn(handle_client(stream, client_id, router_tx)); if shutdown.is_finished() { break; } } Ok(()) } ``` - [ ] **Step 3: Restore on router start and add a persist helper** Change the `router` signature and start it by restoring the snapshot. Replace the `router` function header and registry init: ```rust async fn router( mut rx: mpsc::Receiver, router_tx: mpsc::Sender, exit_tx: mpsc::UnboundedSender<(SurfaceId, i32)>, persister: Persister, initial: crate::state_store::PersistState, ) { let mut reg = Registry::new(); reg.restore(initial); let mut clients: HashMap = HashMap::new(); let mut subs: HashMap> = HashMap::new(); // Persist whatever structure was just restored / changed. let persist = |reg: &Registry| persister.mark_dirty(reg.persist_state()); while let Some(msg) = rx.recv().await { match msg { // ... existing arms (ClientConnected/Disconnected/Output) unchanged ... ServerMsg::Exit { surface_id, code } => { // Transition running -> stopped; keep panel + tree. reg.mark_stopped(&surface_id); let evt = Envelope::Evt(Evt::Exit { surface_id: surface_id.clone(), code }); broadcast_evt(&clients, &evt); } ServerMsg::Request { id, cmd, client, out } => { handle_request(id, cmd, client, out, &mut reg, &mut subs, &clients, &router_tx, &exit_tx, &persister).await; } } } } ``` Keep the `ClientConnected`, `ClientDisconnected`, and `Output` arms exactly as they are today. The `persist` closure above is illustrative — call `persister.mark_dirty(reg.persist_state())` directly inside `handle_request` after each structural mutation (closures borrowing `reg` across the await are awkward; call the method form). - [ ] **Step 4: Rewrite `handle_request` for the new API + M2 commands** Replace the whole `handle_request` function. It is large; below is the complete body. Helper `ok`/`err` are unchanged from M0+M1. ```rust #[allow(clippy::too_many_arguments)] async fn handle_request( id: u64, cmd: Cmd, client: ClientId, out: ClientTx, reg: &mut Registry, subs: &mut HashMap>, clients: &HashMap, router_tx: &mpsc::Sender, exit_tx: &mpsc::UnboundedSender<(SurfaceId, i32)>, persister: &Persister, ) { use spacesh_proto::message::{SplitDir, Edge}; use spacesh_proto::layout::{LayoutNode, Orient}; use spacesh_proto::workspace::SurfaceSpec; match cmd { Cmd::Open { path } => { let (ws_id, created) = reg.open_workspace(path.into()); if created { if let Some(view) = reg.workspace_view(&ws_id) { broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceChanged { workspace: view })); } persister.mark_dirty(reg.persist_state()); } let _ = out.send(ok(id, serde_json::json!({ "workspace_id": ws_id.0 }))).await; } Cmd::NewSurface { workspace_id, command, args, cols, rows } => { let Some(ws) = reg.workspace(&workspace_id).cloned() else { let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return; }; let sid = reg.new_surface_id(); let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into())); let spec = SurfaceSpec { command: shell, args: args.clone(), cwd: ws.path.clone(), agent_label: command, cols, rows, autostart: false, }; match crate::surface::spawn_from_spec(sid.clone(), workspace_id.clone(), &spec, exit_tx.clone()) { Ok(handle) => { spawn_output_bridge(sid.clone(), &handle, router_tx.clone()); reg.set_live(handle); reg.add_surface_spec(&workspace_id, sid.clone(), spec); // First panel of an empty workspace becomes the root leaf. if let Some(w) = reg.workspace_mut(&workspace_id) { if w.layout.is_none() { w.layout = Some(LayoutNode::leaf(sid.clone())); } } broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated { surface_id: sid.clone(), workspace_id: workspace_id.clone(), })); emit_layout(reg, &workspace_id, clients); persister.mark_dirty(reg.persist_state()); let _ = out.send(ok(id, serde_json::json!({ "surface_id": sid.0 }))).await; } Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; } } } Cmd::SplitSurface { surface_id, dir, command, args } => { let Some(ws_id) = reg.workspace_of(&surface_id) else { let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return; }; let ws = reg.workspace(&ws_id).cloned().unwrap(); let new_sid = reg.new_surface_id(); let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into())); let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false }; match crate::surface::spawn_from_spec(new_sid.clone(), ws_id.clone(), &spec, exit_tx.clone()) { Ok(handle) => { spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone()); reg.set_live(handle); reg.add_surface_spec(&ws_id, new_sid.clone(), spec); let orient = match dir { SplitDir::Right => Orient::H, SplitDir::Down => Orient::V }; if let Some(w) = reg.workspace_mut(&ws_id) { let mut root = w.layout.take().unwrap_or_else(|| LayoutNode::leaf(surface_id.clone())); spacesh_core::ops::split_leaf(&mut root, &surface_id, orient, true, new_sid.clone()); w.layout = Some(root); } broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated { surface_id: new_sid.clone(), workspace_id: ws_id.clone() })); emit_layout(reg, &ws_id, clients); persister.mark_dirty(reg.persist_state()); let _ = out.send(ok(id, serde_json::json!({ "surface_id": new_sid.0 }))).await; } Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; } } } Cmd::SetRatios { workspace_id, node_path, ratios } => { let ok_set = reg.workspace_mut(&workspace_id).map(|w| { if let Some(l) = w.layout.as_mut() { spacesh_core::ops::set_ratios(l, &node_path, &ratios) } else { false } }).unwrap_or(false); if ok_set { emit_layout(reg, &workspace_id, clients); persister.mark_dirty(reg.persist_state()); let _ = out.send(ok(id, serde_json::Value::Null)).await; } else { let _ = out.send(err(id, "BAD_REQUEST", "invalid node_path or ratios")).await; } } Cmd::MoveSurface { surface_id, target_surface_id, edge } => { let Some(ws_id) = reg.workspace_of(&surface_id) else { let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return; }; if let Some(w) = reg.workspace_mut(&ws_id) { if let Some(root) = w.layout.take() { w.layout = Some(spacesh_core::ops::move_leaf(root, &surface_id, &target_surface_id, edge)); } } emit_layout(reg, &ws_id, clients); persister.mark_dirty(reg.persist_state()); let _ = out.send(ok(id, serde_json::Value::Null)).await; } Cmd::ApplyPreset { workspace_id, preset_id, slots } => { let Some(count) = spacesh_core::presets::slot_count(&preset_id) else { let _ = out.send(err(id, "BAD_REQUEST", "unknown preset")).await; return; }; let Some(ws) = reg.workspace(&workspace_id).cloned() else { let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; return; }; // Kill current panels of this workspace. let existing: Vec = ws.surfaces.keys().cloned().collect(); for sid in &existing { if let Some(h) = reg.live(sid) { let _ = h.tx.send(crate::surface::SurfaceMsg::Close).await; } reg.remove_surface(sid); subs.remove(sid); } // Spawn `count` panels (slots padded/truncated to count). let mut new_ids = Vec::new(); for i in 0..count { let slot = slots.get(i); let new_sid = reg.new_surface_id(); let command = slot.and_then(|s| s.command.clone()); let shell = command.clone().unwrap_or_else(|| std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into())); let args = slot.map(|s| s.args.clone()).unwrap_or_default(); let spec = SurfaceSpec { command: shell, args, cwd: ws.path.clone(), agent_label: command, cols: 80, rows: 24, autostart: false }; match crate::surface::spawn_from_spec(new_sid.clone(), workspace_id.clone(), &spec, exit_tx.clone()) { Ok(handle) => { spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone()); reg.set_live(handle); reg.add_surface_spec(&workspace_id, new_sid.clone(), spec); new_ids.push(new_sid); } Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; return; } } } if let Some(tree) = spacesh_core::presets::build(&preset_id, &new_ids) { if let Some(w) = reg.workspace_mut(&workspace_id) { w.layout = Some(tree); } } for sid in &new_ids { broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceCreated { surface_id: sid.clone(), workspace_id: workspace_id.clone() })); } emit_layout(reg, &workspace_id, clients); persister.mark_dirty(reg.persist_state()); let _ = out.send(ok(id, serde_json::json!({ "surface_ids": new_ids.iter().map(|s| s.0.clone()).collect::>() }))).await; } Cmd::RestartSurface { surface_id } => { if reg.is_running(&surface_id) { let _ = out.send(ok(id, serde_json::Value::Null)).await; return; // already running } let Some(spec) = reg.surface_spec(&surface_id) else { let _ = out.send(err(id, "NOT_FOUND", "surface")).await; return; }; let ws_id = reg.workspace_of(&surface_id).unwrap(); match crate::surface::spawn_from_spec(surface_id.clone(), ws_id.clone(), &spec, exit_tx.clone()) { Ok(handle) => { spawn_output_bridge(surface_id.clone(), &handle, router_tx.clone()); reg.set_live(handle); broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceRestarted { surface_id: surface_id.clone() })); let _ = out.send(ok(id, serde_json::Value::Null)).await; } Err(e) => { let _ = out.send(err(id, "SPAWN_FAILED", &e.to_string())).await; } } } Cmd::CloseWorkspace { workspace_id } => { let ids = reg.close_workspace(&workspace_id); for sid in &ids { subs.remove(sid); } broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceClosed { workspace_id: workspace_id.clone() })); persister.mark_dirty(reg.persist_state()); let _ = out.send(ok(id, serde_json::Value::Null)).await; } Cmd::SetWorkspaceMeta { workspace_id, name, group_id, unread, order } => { let found = reg.workspace_mut(&workspace_id).map(|w| { if let Some(n) = name { w.name = n; } if let Some(g) = group_id { w.group_id = g; } if let Some(u) = unread { w.unread = u; } if let Some(o) = order { w.order = o; } }).is_some(); if found { if let Some(view) = reg.workspace_view(&workspace_id) { broadcast_evt(clients, &Envelope::Evt(Evt::WorkspaceChanged { workspace: view })); } persister.mark_dirty(reg.persist_state()); let _ = out.send(ok(id, serde_json::Value::Null)).await; } else { let _ = out.send(err(id, "NOT_FOUND", "workspace")).await; } } Cmd::CreateGroup { name, color } => { let gid = reg.create_group(name, color); broadcast_evt(clients, &Envelope::Evt(Evt::GroupsChanged { groups: reg.groups() })); persister.mark_dirty(reg.persist_state()); let _ = out.send(ok(id, serde_json::json!({ "group_id": gid.0 }))).await; } Cmd::SetGroup { group_id, name, color, order } => { let found = reg.group_mut(&group_id).map(|g| { if let Some(n) = name { g.name = n; } if let Some(c) = color { g.color = c; } if let Some(o) = order { g.order = o; } }).is_some(); if found { broadcast_evt(clients, &Envelope::Evt(Evt::GroupsChanged { groups: reg.groups() })); persister.mark_dirty(reg.persist_state()); let _ = out.send(ok(id, serde_json::Value::Null)).await; } else { let _ = out.send(err(id, "NOT_FOUND", "group")).await; } } Cmd::DeleteGroup { group_id } => { reg.delete_group(&group_id); broadcast_evt(clients, &Envelope::Evt(Evt::GroupsChanged { groups: reg.groups() })); persister.mark_dirty(reg.persist_state()); let _ = out.send(ok(id, serde_json::Value::Null)).await; } Cmd::Input { surface_id, bytes } => { let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&bytes) else { let _ = out.send(err(id, "BAD_REQUEST", "invalid base64")).await; return; }; if let Some(s) = reg.live(&surface_id) { let _ = s.tx.send(crate::surface::SurfaceMsg::Input(decoded)).await; let _ = out.send(ok(id, serde_json::Value::Null)).await; } else { let _ = out.send(err(id, "NOT_FOUND", "surface")).await; } } Cmd::Resize { surface_id, cols, rows } => { if let Some(s) = reg.live(&surface_id) { let _ = s.tx.send(crate::surface::SurfaceMsg::Resize { cols, rows }).await; let _ = out.send(ok(id, serde_json::Value::Null)).await; } else { let _ = out.send(err(id, "NOT_FOUND", "surface")).await; } } Cmd::Attach { surface_id } => { if let Some(s) = reg.live(&surface_id) { let (reply_tx, reply_rx) = oneshot::channel(); if s.tx.send(crate::surface::SurfaceMsg::AttachSnapshot { reply: reply_tx }).await.is_ok() { if let Ok((snap, _sub)) = reply_rx.await { subs.entry(surface_id.clone()).or_default().push(client); let _ = out.send(ok(id, serde_json::json!({ "snapshot": snap.ansi, "cols": snap.cols, "rows": snap.rows, "cursor_row": snap.cursor_row, "cursor_col": snap.cursor_col, }))).await; return; } } let _ = out.send(err(id, "INTERNAL", "attach failed")).await; } else { // stopped panel: no live stream, return an empty snapshot so the GUI shows the restart overlay. let _ = out.send(ok(id, serde_json::json!({ "snapshot": "", "cols": 0, "rows": 0, "stopped": true }))).await; } } Cmd::Detach { surface_id } => { if let Some(list) = subs.get_mut(&surface_id) { list.retain(|c| *c != client); } let _ = out.send(ok(id, serde_json::Value::Null)).await; } Cmd::Focus { surface_id: _ } => { let _ = out.send(ok(id, serde_json::Value::Null)).await; } Cmd::Close { surface_id } => { if reg.surface_spec(&surface_id).is_some() { if let Some(h) = reg.live(&surface_id) { let _ = h.tx.send(crate::surface::SurfaceMsg::Close).await; } let ws_id = reg.workspace_of(&surface_id); reg.remove_surface(&surface_id); subs.remove(&surface_id); broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceClosed { surface_id: surface_id.clone() })); if let Some(ws_id) = ws_id { emit_layout(reg, &ws_id, clients); } persister.mark_dirty(reg.persist_state()); let _ = out.send(ok(id, serde_json::Value::Null)).await; } else { let _ = out.send(err(id, "NOT_FOUND", "surface")).await; } } Cmd::Status => { let (groups, workspaces) = reg.status(); let _ = out.send(ok(id, serde_json::json!({ "groups": groups, "workspaces": workspaces }))).await; } Cmd::Shutdown => { let _ = out.send(ok(id, serde_json::Value::Null)).await; std::process::exit(0); } } } /// Emit a `layout_changed` event for a workspace's current tree. fn emit_layout(reg: &Registry, ws_id: &WorkspaceId, clients: &HashMap) { if let Some(w) = reg.workspace(ws_id) { broadcast_evt(clients, &Envelope::Evt(Evt::LayoutChanged { workspace_id: ws_id.clone(), layout: w.layout.clone(), })); } } ``` Update the imports at the top of `server.rs` to include the new proto items used: `use spacesh_proto::{Cmd, Envelope, ErrorBody, Evt, SurfaceId, WorkspaceId};` (and keep `codec`, `base64::Engine`). - [ ] **Step 2: Update the existing M0+M1 server tests for the new API** Existing tests call the old `req`/helpers — those still work (wire-level). But `unknown_surface_returns_not_found` sends `Input` to `s_nope`; with the new code that returns `NOT_FOUND` (live lookup) — still passes. `open_new_surface_attach_streams_output` and `reattach_returns_snapshot_with_prior_output`: `status`/`open`/`new_surface`/`attach` wire shapes are unchanged for those flows, so they pass. Keep `tempdir_path`, `wait_for_socket`, the `serial()` guard, and the `multi_thread` flavor. The only change: tests that call `serve(&sock)` must now pass a store — update those call sites: ```rust let store: std::sync::Arc = std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); let store2 = store.clone(); tokio::spawn(async move { let _ = serve(&sock_for_task, store2).await; }); ``` Apply to all three existing integration tests (each constructs `dir`/`sock` already). - [ ] **Step 3: Add M2 integration tests** Append to the `tests` module in `server.rs`: ```rust #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn apply_preset_builds_tree_and_status_reports_it() { let _serial = crate::test_support::serial(); let dir = tempdir_path(); let sock = dir.join("sock"); let store: std::sync::Arc = std::sync::Arc::new(crate::state_store::JsonStateStore::new(dir.join("state.json"))); let sock2 = sock.clone(); tokio::spawn(async move { let _ = serve(&sock2, store).await; }); wait_for_socket(&sock).await; let mut s = UnixStream::connect(&sock).await.unwrap(); let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await; let ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string(); let r = req(&mut s, 2, Cmd::ApplyPreset { workspace_id: spacesh_proto::WorkspaceId(ws.clone()), preset_id: "2x2".into(), slots: vec![], }).await; let ids = res_data(&r)["surface_ids"].as_array().unwrap(); assert_eq!(ids.len(), 4); let r = req(&mut s, 3, Cmd::Status).await; let wss = res_data(&r)["workspaces"].as_array().unwrap(); let w0 = wss.iter().find(|w| w["id"] == ws).unwrap(); assert!(w0["layout"].is_object(), "layout tree present"); assert!(w0["layout"].to_string().contains("split")); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cold_restart_restores_structure_stopped() { let _serial = crate::test_support::serial(); let dir = tempdir_path(); let state_path = dir.join("state.json"); let sock = dir.join("sock"); let ws; { let store: std::sync::Arc = std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone())); let sock2 = sock.clone(); tokio::spawn(async move { let _ = serve(&sock2, store).await; }); wait_for_socket(&sock).await; let mut s = UnixStream::connect(&sock).await.unwrap(); let r = req(&mut s, 1, Cmd::Open { path: std::env::temp_dir().to_string_lossy().into() }).await; ws = res_data(&r)["workspace_id"].as_str().unwrap().to_string(); req(&mut s, 2, Cmd::ApplyPreset { workspace_id: spacesh_proto::WorkspaceId(ws.clone()), preset_id: "2tb".into(), slots: vec![], }).await; // allow debounce (500ms) to flush state.json tokio::time::sleep(tokio::time::Duration::from_millis(900)).await; } // "cold start": new store on the same state file, new socket. let sock_b = dir.join("sock2"); let store_b: std::sync::Arc = std::sync::Arc::new(crate::state_store::JsonStateStore::new(state_path.clone())); let sb2 = sock_b.clone(); tokio::spawn(async move { let _ = serve(&sock_b, store_b).await; }); wait_for_socket(&sb2).await; let mut s2 = UnixStream::connect(&sb2).await.unwrap(); let r = req(&mut s2, 1, Cmd::Status).await; let wss = res_data(&r)["workspaces"].as_array().unwrap(); let w0 = wss.iter().find(|w| w["id"] == ws).expect("workspace restored"); let surfaces = w0["surfaces"].as_object().unwrap(); assert_eq!(surfaces.len(), 2, "2tb panels restored"); for (_id, sv) in surfaces { assert_eq!(sv["running"], false, "restored panels are stopped"); } } ``` - [ ] **Step 4: Run the full suite (3×) to confirm green & non-flaky** Run: `cargo test --workspace > /tmp/m2.log 2>&1; echo EXIT=$?` — repeat 3×. All three must be 0. Expected: all crate tests pass, including the new layout/preset/state_store/persist/registry/server tests. - [ ] **Step 5: Commit** ```bash git add crates/spaceshd/src/server.rs crates/spaceshd/src/main.rs crates/spaceshd/src/registry.rs crates/spaceshd/src/surface.rs git commit -m "feat(daemon): M2 command dispatch, layout events, cold-start restore, persistence wiring" ``` --- ## Phase 8 — app: layout engine & sidebar ### Task 11: TS types + bridge for M2 **Files:** - Create: `app/src/layoutTypes.ts` - Modify: `app/src/socketBridge.ts` - [ ] **Step 1: Layout TS types** Create `app/src/layoutTypes.ts`: ```ts export type Orient = "h" | "v"; export type LayoutNode = | { leaf: { surface_id: string } } | { split: { orient: Orient; ratios: number[]; children: LayoutNode[] } }; export interface SurfaceView { spec: { command: string; args: string[]; cwd: string; agent_label: string | null; cols: number; rows: number; autostart: boolean; }; running: boolean; } export interface Group { id: string; name: string; color: string; order: number; } export interface WorkspaceView { id: string; path: string; name: string; group_id: string | null; order: number; unread: boolean; layout: LayoutNode | null; surfaces: Record; } export function leafIds(node: LayoutNode | null): string[] { if (!node) return []; if ("leaf" in node) return [node.leaf.surface_id]; return node.split.children.flatMap(leafIds); } ``` - [ ] **Step 2: Extend the bridge** Append to `app/src/socketBridge.ts` (and update `getStatus` + event types): ```ts import type { Group, WorkspaceView, LayoutNode } from "./layoutTypes"; export interface StatusResult { groups: Group[]; workspaces: WorkspaceView[]; } export async function getStatusFull(): Promise { return await invoke("status"); } export async function splitSurface(surfaceId: string, dir: "right" | "down", command?: string, args: string[] = []): Promise { const data = await invoke<{ surface_id: string }>("split_surface", { surfaceId, dir, command: command ?? null, args }); return data.surface_id; } export async function setRatios(workspaceId: string, nodePath: number[], ratios: number[]): Promise { await invoke("set_ratios", { workspaceId, nodePath, ratios }); } export async function moveSurface(surfaceId: string, targetSurfaceId: string, edge: "left" | "right" | "top" | "bottom"): Promise { await invoke("move_surface", { surfaceId, targetSurfaceId, edge }); } export async function applyPreset(workspaceId: string, presetId: string, slots: { command?: string; args?: string[] }[]): Promise { const data = await invoke<{ surface_ids: string[] }>("apply_preset", { workspaceId, presetId, slots: slots.map((s) => ({ command: s.command ?? null, args: s.args ?? [] })), }); return data.surface_ids; } export async function restartSurface(surfaceId: string): Promise { await invoke("restart_surface", { surfaceId }); } export async function closeWorkspaceCmd(workspaceId: string): Promise { await invoke("close_workspace", { workspaceId }); } export async function setWorkspaceMeta(workspaceId: string, meta: { name?: string; groupId?: string | null; unread?: boolean; order?: number }): Promise { await invoke("set_workspace_meta", { workspaceId, name: meta.name ?? null, groupId: meta.groupId === undefined ? null : meta.groupId, unread: meta.unread ?? null, order: meta.order ?? null, }); } export async function createGroup(name: string, color: string): Promise { const data = await invoke<{ group_id: string }>("create_group", { name, color }); return data.group_id; } export async function closeSurfaceCmd(surfaceId: string): Promise { await invoke("close_surface", { surfaceId }); } ``` Note: the Tauri command for `set_workspace_meta`'s `group_id: Option>` — the bridge command in `bridge.rs` (Task 13) maps the JS `groupId` (string | null | undefined) accordingly. Keep `getStatus` (M0+M1) for back-compat or migrate callers to `getStatusFull`. - [ ] **Step 3: Type-check** Run: `cd app && npm run build` Expected: PASS (tsc + vite). (Components using these come in Tasks 12–14; this step only checks the new types/bridge compile.) - [ ] **Step 4: Commit** ```bash git add app/src/layoutTypes.ts app/src/socketBridge.ts git commit -m "feat(app): M2 layout TS types + bridge commands" ``` --- ### Task 12: LayoutEngine — recursive render + splitter resize + stopped overlay **Files:** - Create: `app/src/LayoutEngine.tsx` - [ ] **Step 1: Implement LayoutEngine** Create `app/src/LayoutEngine.tsx`: ```tsx import { useRef } from "react"; import { TerminalView } from "./TerminalView"; import type { LayoutNode } from "./layoutTypes"; import { setRatios, restartSurface } from "./socketBridge"; interface Props { workspaceId: string; layout: LayoutNode | null; /** surface_id -> running flag, from the latest status/events. */ running: Record; } export function LayoutEngine({ workspaceId, layout, running }: Props) { if (!layout) { return
Empty workspace — apply a preset to add panels.
; } return ; } function Node({ workspaceId, node, path, running }: { workspaceId: string; node: LayoutNode; path: number[]; running: Record }) { if ("leaf" in node) { const id = node.leaf.surface_id; if (running[id] === false) { return (
Process exited
); } return ; } const { orient, ratios, children } = node.split; const dir = orient === "h" ? "row" : "column"; return (
{children.map((child, i) => ( { const next = [...ratios]; next[i] = Math.max(0.05, next[i] + deltaFrac); next[i + 1] = Math.max(0.05, next[i + 1] - deltaFrac); void setRatios(workspaceId, path, next); }}> ))}
); } function Pane({ grow, isLast, orient, onResize, children }: { grow: number; isLast: boolean; orient: "h" | "v"; onResize: (deltaFrac: number) => void; children: React.ReactNode }) { const ref = useRef(null); const startDrag = (e: React.MouseEvent) => { e.preventDefault(); const parent = ref.current?.parentElement; if (!parent) return; const total = orient === "h" ? parent.clientWidth : parent.clientHeight; const start = orient === "h" ? e.clientX : e.clientY; let last = start; const move = (ev: MouseEvent) => { const cur = orient === "h" ? ev.clientX : ev.clientY; const delta = (cur - last) / total; last = cur; onResize(delta); }; const up = () => { window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); }; window.addEventListener("mousemove", move); window.addEventListener("mouseup", up); }; return ( <>
{children}
{!isLast && (
)} ); } ``` Note: `setRatios` is throttled at the daemon/visual level by React batching; for smoother drag the implementer may add a ~30ms throttle, but per-move calls are acceptable for the slice (resize is occasional). The daemon clamps and normalizes ratios authoritatively, and `layout_changed` re-syncs. - [ ] **Step 2: Type-check** Run: `cd app && npm run build` Expected: PASS. - [ ] **Step 3: Commit** ```bash git add app/src/LayoutEngine.tsx git commit -m "feat(app): LayoutEngine — recursive split render, splitter resize, stopped overlay" ``` --- ### Task 13: Tauri bridge commands for M2 **Files:** - Modify: `app/src-tauri/src/bridge.rs` - Modify: `app/src-tauri/src/lib.rs` (register new handlers) - [ ] **Step 1: Add the command wrappers** Append to `app/src-tauri/src/bridge.rs` (before the existing `close_surface` or alongside the other `#[tauri::command]` fns), each mirroring the M0+M1 pattern (`state.request(Cmd::...).await` → `data_of`): ```rust use spacesh_proto::message::{SplitDir, Edge, PresetSlot}; use spacesh_proto::ids::{GroupId, WorkspaceId}; #[tauri::command] pub async fn split_surface(state: BridgeState<'_>, surface_id: String, dir: String, command: Option, args: Vec) -> Result { let dir = if dir == "down" { SplitDir::Down } else { SplitDir::Right }; data_of(state.request(Cmd::SplitSurface { surface_id: SurfaceId(surface_id), dir, command, args }).await.map_err(|e| e.to_string())?) } #[tauri::command] pub async fn set_ratios(state: BridgeState<'_>, workspace_id: String, node_path: Vec, ratios: Vec) -> Result { data_of(state.request(Cmd::SetRatios { workspace_id: WorkspaceId(workspace_id), node_path, ratios }).await.map_err(|e| e.to_string())?) } #[tauri::command] pub async fn move_surface(state: BridgeState<'_>, surface_id: String, target_surface_id: String, edge: String) -> Result { let edge = match edge.as_str() { "left" => Edge::Left, "top" => Edge::Top, "bottom" => Edge::Bottom, _ => Edge::Right }; data_of(state.request(Cmd::MoveSurface { surface_id: SurfaceId(surface_id), target_surface_id: SurfaceId(target_surface_id), edge }).await.map_err(|e| e.to_string())?) } #[tauri::command] pub async fn apply_preset(state: BridgeState<'_>, workspace_id: String, preset_id: String, slots: Vec) -> Result { data_of(state.request(Cmd::ApplyPreset { workspace_id: WorkspaceId(workspace_id), preset_id, slots }).await.map_err(|e| e.to_string())?) } #[tauri::command] pub async fn restart_surface(state: BridgeState<'_>, surface_id: String) -> Result { data_of(state.request(Cmd::RestartSurface { surface_id: SurfaceId(surface_id) }).await.map_err(|e| e.to_string())?) } #[tauri::command] pub async fn close_workspace(state: BridgeState<'_>, workspace_id: String) -> Result { data_of(state.request(Cmd::CloseWorkspace { workspace_id: WorkspaceId(workspace_id) }).await.map_err(|e| e.to_string())?) } #[tauri::command] pub async fn set_workspace_meta(state: BridgeState<'_>, workspace_id: String, name: Option, group_id: Option, unread: Option, order: Option) -> Result { // group_id: None from JS means "no change"; an explicit null is sent as Some("") to mean "ungroup". let gid = match group_id { None => None, Some(s) if s.is_empty() => Some(None), Some(s) => Some(Some(GroupId(s))), }; data_of(state.request(Cmd::SetWorkspaceMeta { workspace_id: WorkspaceId(workspace_id), name, group_id: gid, unread, order }).await.map_err(|e| e.to_string())?) } #[tauri::command] pub async fn create_group(state: BridgeState<'_>, name: String, color: String) -> Result { data_of(state.request(Cmd::CreateGroup { name, color }).await.map_err(|e| e.to_string())?) } ``` `PresetSlot` derives `Deserialize`, so Tauri can accept it directly from JS (`{ command, args }`). - [ ] **Step 2: Register the handlers** In `app/src-tauri/src/lib.rs`, add the new commands to `tauri::generate_handler![...]`: ```rust bridge::split_surface, bridge::set_ratios, bridge::move_surface, bridge::apply_preset, bridge::restart_surface, bridge::close_workspace, bridge::set_workspace_meta, bridge::create_group, ``` - [ ] **Step 3: Build** Run: `cd app/src-tauri && cargo build` Expected: PASS. - [ ] **Step 4: Commit** ```bash git add app/src-tauri/src/bridge.rs app/src-tauri/src/lib.rs git commit -m "feat(app): tauri bridge commands for M2 (split/ratios/move/preset/restart/groups/meta)" ``` --- ### Task 14: Sidebar, PresetPicker, Wizard, App rewire **Files:** - Create: `app/src/Sidebar.tsx`, `app/src/PresetPicker.tsx`, `app/src/Wizard.tsx` - Modify: `app/src/App.tsx` - [ ] **Step 1: PresetPicker** Create `app/src/PresetPicker.tsx`: ```tsx export const PRESETS: { id: string; label: string; slots: number }[] = [ { id: "1", label: "1", slots: 1 }, { id: "2lr", label: "2↔", slots: 2 }, { id: "2tb", label: "2↕", slots: 2 }, { id: "2+1", label: "2+1", slots: 3 }, { id: "1+2", label: "1+2", slots: 3 }, { id: "3", label: "3", slots: 3 }, { id: "2x2", label: "2×2", slots: 4 }, { id: "4", label: "4", slots: 4 }, { id: "2x3", label: "2×3", slots: 6 }, { id: "2x4", label: "2×4", slots: 8 }, ]; export function PresetPicker({ selected, onSelect }: { selected: string; onSelect: (id: string) => void }) { return (
{PRESETS.map((p) => ( ))}
); } ``` - [ ] **Step 2: Wizard** Create `app/src/Wizard.tsx`: ```tsx import { useState } from "react"; import { PresetPicker, PRESETS } from "./PresetPicker"; import { openWorkspace, applyPreset } from "./socketBridge"; export function Wizard({ onDone, onCancel }: { onDone: (workspaceId: string) => void; onCancel: () => void }) { const [path, setPath] = useState("."); const [preset, setPreset] = useState("2x2"); const [agents, setAgents] = useState([]); const slots = PRESETS.find((p) => p.id === preset)?.slots ?? 1; const agentChoices = ["shell", "claude", "codex", "gemini"]; async function create() { const ws = await openWorkspace(path); const slotSpecs = Array.from({ length: slots }, (_, i) => { const a = agents[i] ?? "shell"; return a === "shell" ? {} : { command: a }; }); await applyPreset(ws, preset, slotSpecs); onDone(ws); } return (
New workspace
setPath(e.target.value)} style={{ width: "100%", margin: "6px 0 16px", padding: 8, background: "#0A0D12", color: "#E6EDF3", border: "1px solid #323C49", borderRadius: 8 }} />
{Array.from({ length: slots }, (_, i) => ( ))}
); } ``` - [ ] **Step 3: Sidebar** Create `app/src/Sidebar.tsx`: ```tsx import type { Group, WorkspaceView } from "./layoutTypes"; export function Sidebar({ groups, workspaces, activeId, onSelect, onNew, }: { groups: Group[]; workspaces: WorkspaceView[]; activeId: string | null; onSelect: (id: string) => void; onNew: () => void; }) { const byGroup = (gid: string | null) => workspaces.filter((w) => (w.group_id ?? null) === gid).sort((a, b) => a.order - b.order); const ungrouped = byGroup(null); const row = (w: WorkspaceView) => (
onSelect(w.id)} style={{ display: "flex", alignItems: "center", gap: 9, padding: "6px 8px", borderRadius: 6, cursor: "pointer", background: w.id === activeId ? "#1A2029" : "transparent", fontFamily: "Inter", fontSize: 13, color: w.id === activeId ? "#E6EDF3" : "#8B97A6", }}> {w.name} {w.unread && } {Object.keys(w.surfaces).length}
); return (
{groups.sort((a, b) => a.order - b.order).map((g) => (
{g.name.toUpperCase()}
{byGroup(g.id).map(row)}
))} {ungrouped.length > 0 &&
{ungrouped.map(row)}
}
); } ``` (Drag-reorder: wire HTML5 drag handlers calling `setWorkspaceMeta({ order, groupId })` / `setGroup({ order })`. Kept out of the minimal component above to stay focused; add as a follow-up step if time allows — the model + commands already support it.) - [ ] **Step 4: Rewire App.tsx around workspaces + LayoutEngine** Replace `app/src/App.tsx`: ```tsx import { useEffect, useState, useCallback } from "react"; import { LayoutEngine } from "./LayoutEngine"; import { Sidebar } from "./Sidebar"; import { PresetPicker } from "./PresetPicker"; import { Wizard } from "./Wizard"; import { getStatusFull, applyPreset, onDaemonEvent, onDaemonRawEvent } from "./socketBridge"; import type { Group, WorkspaceView } from "./layoutTypes"; export function App() { const [groups, setGroups] = useState([]); const [workspaces, setWorkspaces] = useState([]); const [activeId, setActiveId] = useState(null); const [running, setRunning] = useState>({}); const [wizard, setWizard] = useState(false); const refresh = useCallback(async () => { const st = await getStatusFull(); setGroups(st.groups); setWorkspaces(st.workspaces); const run: Record = {}; st.workspaces.forEach((w) => Object.entries(w.surfaces).forEach(([id, sv]) => { run[id] = sv.running; })); setRunning(run); if (!activeId && st.workspaces.length) setActiveId(st.workspaces[0].id); }, [activeId]); useEffect(() => { void refresh(); const unlisten = onDaemonEvent(() => { void refresh(); }); const reconnect = onDaemonRawEvent("spacesh:disconnected", () => { void refresh(); }); return () => { void unlisten.then((f) => f()); void reconnect.then((f) => f()); }; }, [refresh]); const active = workspaces.find((w) => w.id === activeId) ?? null; return (
setWizard(true)} />
{active && (
{ if (active) void applyPreset(active.id, p, []); }} />
)}
{active ? :
No workspace — create one to begin.
}
{wizard && { setWizard(false); setActiveId(id); void refresh(); }} onCancel={() => setWizard(false)} />}
); } ``` - [ ] **Step 5: Build + type-check** Run: `cd app && npm run build && cd src-tauri && cargo build` Expected: both PASS. - [ ] **Step 6: Commit** ```bash git add app/src/Sidebar.tsx app/src/PresetPicker.tsx app/src/Wizard.tsx app/src/App.tsx git commit -m "feat(app): sidebar, preset picker, wizard, App rewired around workspaces + LayoutEngine" ``` --- ## Definition of Done - [ ] `cargo test --workspace` — green & non-flaky across 3 consecutive runs. - [ ] `cd app && npm run build` and `cd app/src-tauri && cargo build` — both clean. - [ ] **Manual** (`npm run tauri dev`): new workspace via wizard with a preset; split a panel (`⌘⇧T`); drag a splitter to resize; apply a preset from the toolbar; close a panel and watch the tree collapse; quit the daemon (`pkill spaceshd`) and relaunch the app → workspaces + layout restored, panels show "stopped" with Restart; restart a stopped panel. ## Notes for the implementer - **Tasks 8–10 compile together.** The registry rewrite (Task 8) breaks `server.rs` until Task 10 updates it. Implement 8 → 9 → 10 and run `cargo test --workspace` at the end of Task 10. Don't expect a green bar between 8 and 10. - **Test robustness:** every new socket/PTY integration test uses `#[tokio::test(flavor = "multi_thread", worker_threads = 2)]` AND `let _serial = crate::test_support::serial();` as the first line — same pattern that de-flaked M1. - **Persistence timing in tests:** the debounce is 500ms; the cold-restart test sleeps 900ms before tearing down so `state.json` is flushed. Don't shorten below ~700ms. - **Single fan-out path preserved:** new surfaces (new_surface/split/preset/restart) all go through `spawn_from_spec` → `spawn_output_bridge` → router → client, exactly like M0+M1. Don't add a second output path. - **Out of slice:** status rings colored by agent state (M3), auto-unread from events (M3), zoom/search/diff/notifications (M5), remote (M6), auth (separate). The status ring in Sidebar is a static placeholder. - **Two documented partials within M2** (model + protocol fully support both; only the wiring is deferred to keep the slice focused — pick them up if time allows): 1. **Cold-start autostart honoring.** `SurfaceSpec.autostart` is persisted and restored, but the router does not auto-`restart_surface` autostart panels on cold start (default is off and no M2 UI sets it true, so the path is untestable end-to-end this slice). To finish: after `reg.restore(initial)` in `router`, iterate restored surfaces with `spec.autostart == true` and spawn each via `spawn_from_spec` + `spawn_output_bridge` + `set_live`, emitting `surface_restarted`. 2. **Sidebar drag-reorder UI.** The full command surface is wired in the bridge — `set_workspace_meta { order, group_id }`, `set_group { name, color, order }`, `delete_group`, `create_group` — so workspace and group reordering/editing are functional via command today. The deferred piece is only the GUI affordance: the `Sidebar` component renders groups/order/unread/count but does not yet emit HTML5 drag handlers. To finish: add `draggable` rows + `onDrop` calling `setWorkspaceMeta`/`setGroup` with the new order. ```