feat(daemon): per-surface status (set_state/state), idle-on-spawn, SPACESH_SOCK override
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,11 @@ pub fn spacesh_dir() -> Result<PathBuf> {
|
||||
}
|
||||
|
||||
pub fn socket_path() -> Result<PathBuf> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, WorkspaceId>,
|
||||
/// Live actors only. Absent id that exists in a workspace's `surfaces` = stopped.
|
||||
live: HashMap<SurfaceId, SurfaceHandle>,
|
||||
/// Ephemeral per-surface status. In-memory only (never persisted).
|
||||
states: HashMap<SurfaceId, SurfaceState>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<dyn crate::state_store::StateStore> =
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user