From 635f9f4356186fbe293cd353deb8182ee180e5fa Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Tue, 9 Jun 2026 22:13:50 +0700 Subject: [PATCH] feat(daemon): per-surface status (set_state/state), idle-on-spawn, SPACESH_SOCK override Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/spaceshd/src/lifecycle.rs | 15 +++++++++ crates/spaceshd/src/registry.rs | 46 +++++++++++++++++++++++++- crates/spaceshd/src/server.rs | 55 +++++++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 2 deletions(-) diff --git a/crates/spaceshd/src/lifecycle.rs b/crates/spaceshd/src/lifecycle.rs index 5ed2da6..26d3736 100644 --- a/crates/spaceshd/src/lifecycle.rs +++ b/crates/spaceshd/src/lifecycle.rs @@ -10,6 +10,11 @@ pub fn spacesh_dir() -> Result { } pub fn socket_path() -> Result { + if let Ok(p) = std::env::var("SPACESH_SOCK") { + if !p.is_empty() { + return Ok(PathBuf::from(p)); + } + } Ok(spacesh_dir()?.join("sock")) } @@ -67,4 +72,14 @@ mod tests { assert!(second.is_none(), "second acquire should be blocked"); drop(first); } + + #[test] + fn socket_path_honors_env_override() { + // Note: set/remove around the assertion; tests in this module run serially enough, + // but guard by restoring afterwards. + std::env::set_var("SPACESH_SOCK", "/tmp/spacesh-test-override.sock"); + let p = socket_path().unwrap(); + std::env::remove_var("SPACESH_SOCK"); + assert_eq!(p, std::path::PathBuf::from("/tmp/spacesh-test-override.sock")); + } } diff --git a/crates/spaceshd/src/registry.rs b/crates/spaceshd/src/registry.rs index cf47eb6..a7a31ff 100644 --- a/crates/spaceshd/src/registry.rs +++ b/crates/spaceshd/src/registry.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use spacesh_proto::ids::{GroupId, SurfaceId, WorkspaceId}; +use spacesh_proto::status::SurfaceState; use spacesh_proto::workspace::{Group, SurfaceSpec, SurfaceView, Workspace, WorkspaceView}; use crate::state_store::PersistState; @@ -18,6 +19,8 @@ pub struct Registry { by_path: HashMap, /// Live actors only. Absent id that exists in a workspace's `surfaces` = stopped. live: HashMap, + /// Ephemeral per-surface status. In-memory only (never persisted). + states: HashMap, } impl Registry { @@ -111,6 +114,18 @@ impl Registry { self.live.contains_key(sid) } + // ---- surface state ---- + + pub fn set_state(&mut self, sid: &SurfaceId, state: SurfaceState) { + self.states.insert(sid.clone(), state); + } + pub fn state(&self, sid: &SurfaceId) -> SurfaceState { + self.states.get(sid).copied().unwrap_or_default() + } + pub fn drop_state(&mut self, sid: &SurfaceId) { + self.states.remove(sid); + } + // ---- groups ---- pub fn create_group(&mut self, name: String, color: String) -> GroupId { @@ -144,7 +159,11 @@ impl Registry { } 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) }) + (sid.clone(), SurfaceView { + spec: spec.clone(), + running: self.live.contains_key(sid), + state: self.state(sid), + }) }).collect(); WorkspaceView { id: w.id.clone(), path: w.path.clone(), name: w.name.clone(), @@ -168,6 +187,7 @@ impl Registry { self.workspaces.clear(); self.by_path.clear(); self.live.clear(); + self.states.clear(); for w in state.workspaces { self.by_path.insert(w.path.clone(), w.id.clone()); self.workspaces.insert(w.id.clone(), w); @@ -243,4 +263,28 @@ mod tests { r.delete_group(&g); assert!(r.workspace(&ws).unwrap().group_id.is_none()); } + + #[test] + fn state_defaults_idle_and_can_be_set() { + 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_eq!(r.state(&sid), spacesh_proto::status::SurfaceState::Idle); + r.set_state(&sid, spacesh_proto::status::SurfaceState::Work); + assert_eq!(r.state(&sid), spacesh_proto::status::SurfaceState::Work); + let v = r.workspace_view(&ws).unwrap(); + assert_eq!(v.surfaces.get(&sid).unwrap().state, spacesh_proto::status::SurfaceState::Work); + } + + #[test] + fn drop_state_resets_to_idle() { + 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()); + r.set_state(&sid, spacesh_proto::status::SurfaceState::Error); + r.drop_state(&sid); + assert_eq!(r.state(&sid), spacesh_proto::status::SurfaceState::Idle); + } } diff --git a/crates/spaceshd/src/server.rs b/crates/spaceshd/src/server.rs index 60759a6..3d45149 100644 --- a/crates/spaceshd/src/server.rs +++ b/crates/spaceshd/src/server.rs @@ -133,8 +133,8 @@ async fn router( } } ServerMsg::Exit { surface_id, code } => { - // Transition running -> stopped; keep panel + tree. reg.mark_stopped(&surface_id); + reg.drop_state(&surface_id); let evt = Envelope::Evt(Evt::Exit { surface_id: surface_id.clone(), code }); broadcast_evt(&clients, &evt); } @@ -211,6 +211,7 @@ async fn handle_request( Ok(handle) => { spawn_output_bridge(sid.clone(), &handle, router_tx.clone()); reg.set_live(handle); + reg.set_state(&sid, spacesh_proto::SurfaceState::Idle); 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) { @@ -241,6 +242,7 @@ async fn handle_request( Ok(handle) => { spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone()); reg.set_live(handle); + reg.set_state(&new_sid, spacesh_proto::SurfaceState::Idle); 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) { @@ -313,6 +315,7 @@ async fn handle_request( Ok(handle) => { spawn_output_bridge(new_sid.clone(), &handle, router_tx.clone()); reg.set_live(handle); + reg.set_state(&new_sid, spacesh_proto::SurfaceState::Idle); reg.add_surface_spec(&workspace_id, new_sid.clone(), spec); new_ids.push(new_sid); } @@ -342,6 +345,7 @@ async fn handle_request( Ok(handle) => { spawn_output_bridge(surface_id.clone(), &handle, router_tx.clone()); reg.set_live(handle); + reg.set_state(&surface_id, spacesh_proto::SurfaceState::Idle); broadcast_evt(clients, &Envelope::Evt(Evt::SurfaceRestarted { surface_id: surface_id.clone() })); let _ = out.send(ok(id, serde_json::Value::Null)).await; } @@ -467,6 +471,17 @@ async fn handle_request( } } + Cmd::SetState { 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 })); + let _ = out.send(ok(id, serde_json::Value::Null)).await; + } else { + // unknown or stopped surface — status is only meaningful while running. + let _ = out.send(err(id, "NOT_FOUND", "surface not running")).await; + } + } + Cmd::Status => { let (groups, workspaces) = reg.status(); let _ = out.send(ok(id, serde_json::json!({ "groups": groups, "workspaces": workspaces }))).await; @@ -677,6 +692,44 @@ mod tests { assert!(w0["layout"].to_string().contains("split")); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn set_state_updates_status_and_emits_event() { + 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::NewSurface { + workspace_id: spacesh_proto::WorkspaceId(ws.clone()), + command: Some("/bin/sh".into()), + args: vec!["-c".into(), "sleep 1".into()], + cols: 80, rows: 24, + }).await; + let sid = res_data(&r)["surface_id"].as_str().unwrap().to_string(); + let surface_id = spacesh_proto::SurfaceId(sid.clone()); + + // set_state on the running surface + let r = req(&mut s, 3, Cmd::SetState { surface_id: surface_id.clone(), state: spacesh_proto::status::SurfaceState::Work }).await; + assert!(matches!(r, Envelope::Res { ok: true, .. })); + + // status reflects it + let r = req(&mut s, 4, Cmd::Status).await; + let wss = res_data(&r)["workspaces"].as_array().unwrap(); + let w0 = wss.iter().find(|w| w["id"] == ws).unwrap(); + assert_eq!(w0["surfaces"][&sid]["state"], "work"); + + // unknown surface -> NOT_FOUND + let r = req(&mut s, 5, Cmd::SetState { surface_id: spacesh_proto::SurfaceId("s_nope".into()), state: spacesh_proto::status::SurfaceState::Done }).await; + match r { Envelope::Res { ok, error, .. } => { assert!(!ok); assert_eq!(error.unwrap().code, "NOT_FOUND"); }, _ => panic!() } + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cold_restart_restores_structure_stopped() { let _serial = crate::test_support::serial();