Skip to main content

prospero_core/
registry.rs

1//! The persisted set of workspaces Prospero manages.
2//!
3//! The fleet is intentional, not guessed: a blind socket scan can't map a
4//! `hash16` socket name back to a workspace, so operators register workspaces by
5//! name. A workspace is a root directory holding 1..N source checkouts
6//! (caliban #281 / ADR 0052); its caliband is keyed on `hash16(root)`.
7
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::{CoreError, Result};
13
14// The per-repo provider config DTO now lives in `prospero-types` (shared with the
15// WASM dashboard, prospero #98); re-exported here from its original path.
16pub use prospero_types::RepoProviderConfig;
17pub use prospero_types::{
18    CredentialsRef, IsolationConfig, ProviderInfo, ProviderSpec, WorkspaceConfig, WorkspaceInfo,
19    WorkspaceSourceSpec, WorkspaceStatusInfo,
20};
21
22/// A single managed workspace's *persisted* identity: name + root + config.
23/// Sources are discovered from the filesystem at snapshot-build time (they are
24/// not persisted), so they live on the runtime [`crate::model::Workspace`] view.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct RegisteredWorkspace {
27    /// Operator-chosen short name (registry key).
28    pub name: String,
29    /// Canonical workspace root path (the caliband is keyed on `hash16(root)`).
30    pub root: PathBuf,
31    /// Provider/environment config for this workspace's caliband daemon.
32    #[serde(default)]
33    pub config: RepoProviderConfig,
34}
35
36/// The persisted registry of managed workspaces.
37#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
38pub struct Registry {
39    /// Registered workspaces, keyed by unique `name`. The `repos` alias lets a
40    /// legacy on-disk registry (`{"repos":[...]}`, pre-#72) load unchanged.
41    #[serde(alias = "repos")]
42    pub workspaces: Vec<RegisteredWorkspace>,
43}
44
45impl Registry {
46    /// Load the registry from `path`, returning an empty registry if the file
47    /// does not exist yet.
48    pub fn load(path: &Path) -> Result<Self> {
49        match std::fs::read(path) {
50            Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
51            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Registry::default()),
52            Err(e) => Err(e.into()),
53        }
54    }
55
56    /// Persist the registry to `path` (creating parent dirs).
57    pub fn save(&self, path: &Path) -> Result<()> {
58        if let Some(parent) = path.parent() {
59            std::fs::create_dir_all(parent)?;
60        }
61        let json = serde_json::to_vec_pretty(self)?;
62        std::fs::write(path, json)?;
63        Ok(())
64    }
65
66    /// Look up a workspace by name.
67    pub fn get(&self, name: &str) -> Option<&RegisteredWorkspace> {
68        self.workspaces.iter().find(|r| r.name == name)
69    }
70
71    /// Register a workspace. Errors if the name is already taken (idempotent only
72    /// when the existing entry has the same root).
73    pub fn add(&mut self, name: impl Into<String>, root: impl Into<PathBuf>) -> Result<()> {
74        let name = name.into();
75        let root = root.into();
76        if let Some(existing) = self.get(&name) {
77            if existing.root == root {
78                return Ok(());
79            }
80            return Err(CoreError::Conflict(format!(
81                "workspace name '{name}' already registered with a different root"
82            )));
83        }
84        // Reject a *different* name occupying the same root: two names for one
85        // root alias a single caliband daemon, so both poll the same agents and
86        // double-emit into the same event stream. Roots are canonicalized
87        // before they reach here (see `FleetManager::add_workspace_with_config`),
88        // so this also catches symlink aliases like `/tmp` vs `/private/tmp`. (#47)
89        if let Some(other) = self.workspaces.iter().find(|r| r.root == root) {
90            return Err(CoreError::Conflict(format!(
91                "root {} is already registered as workspace '{}'",
92                root.display(),
93                other.name
94            )));
95        }
96        self.workspaces.push(RegisteredWorkspace {
97            name,
98            root,
99            config: RepoProviderConfig::default(),
100        });
101        Ok(())
102    }
103
104    /// Remove a workspace by name. Returns whether an entry was removed.
105    pub fn remove(&mut self, name: &str) -> bool {
106        let before = self.workspaces.len();
107        self.workspaces.retain(|r| r.name != name);
108        self.workspaces.len() != before
109    }
110
111    /// Replace a workspace's provider config. Returns whether it existed.
112    pub fn set_config(&mut self, name: &str, config: RepoProviderConfig) -> bool {
113        if let Some(r) = self.workspaces.iter_mut().find(|r| r.name == name) {
114            r.config = config;
115            true
116        } else {
117            false
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn add_get_remove() {
128        let mut reg = Registry::default();
129        reg.add("prospero", "/dev/prospero").unwrap();
130        assert_eq!(
131            reg.get("prospero").unwrap().root,
132            PathBuf::from("/dev/prospero")
133        );
134        assert!(reg.remove("prospero"));
135        assert!(reg.get("prospero").is_none());
136        assert!(!reg.remove("prospero"));
137    }
138
139    #[test]
140    fn add_same_name_same_root_is_idempotent() {
141        let mut reg = Registry::default();
142        reg.add("p", "/r").unwrap();
143        reg.add("p", "/r").unwrap();
144        assert_eq!(reg.workspaces.len(), 1);
145    }
146
147    #[test]
148    fn add_same_name_different_root_errors() {
149        let mut reg = Registry::default();
150        reg.add("p", "/r1").unwrap();
151        assert!(reg.add("p", "/r2").is_err());
152    }
153
154    #[test]
155    fn add_different_name_same_root_errors() {
156        // Two names for one root would alias a single caliban daemon and
157        // double-emit events into the same agent stream. (#47)
158        let mut reg = Registry::default();
159        reg.add("a", "/r").unwrap();
160        let err = reg.add("b", "/r").unwrap_err().to_string();
161        assert!(err.contains("workspace 'a'"), "names the holder: {err}");
162        assert_eq!(reg.workspaces.len(), 1, "the alias must not be registered");
163    }
164
165    #[test]
166    fn legacy_repos_json_loads_via_alias() {
167        let dir = tempfile::tempdir().unwrap();
168        let path = dir.path().join("registry.json");
169        // Old on-disk shape used the "repos" key; the alias loads it unchanged.
170        std::fs::write(&path, r#"{"repos":[{"name":"p","root":"/r"}]}"#).unwrap();
171        let reg = Registry::load(&path).unwrap();
172        let ws = reg.get("p").expect("legacy entry loads");
173        assert_eq!(ws.root, PathBuf::from("/r"));
174        assert_eq!(ws.config, RepoProviderConfig::default());
175    }
176
177    #[test]
178    fn load_missing_file_is_empty() {
179        let dir = tempfile::tempdir().unwrap();
180        let path = dir.path().join("registry.json");
181        let reg = Registry::load(&path).unwrap();
182        assert!(reg.workspaces.is_empty());
183    }
184
185    #[test]
186    fn save_then_load_round_trips() {
187        let dir = tempfile::tempdir().unwrap();
188        let path = dir.path().join("nested/registry.json");
189        let mut reg = Registry::default();
190        reg.add("a", "/a").unwrap();
191        reg.add("b", "/b").unwrap();
192        reg.save(&path).unwrap();
193        let loaded = Registry::load(&path).unwrap();
194        assert_eq!(reg, loaded);
195    }
196
197    #[test]
198    fn repo_config_defaults_empty() {
199        let c = RepoProviderConfig::default();
200        assert!(
201            c.provider.is_none()
202                && c.base_url.is_none()
203                && c.api_key_from_env.is_none()
204                && c.env.is_empty()
205        );
206    }
207
208    #[test]
209    fn old_registry_json_without_config_loads_with_default() {
210        let dir = tempfile::tempdir().unwrap();
211        let path = dir.path().join("registry.json");
212        std::fs::write(&path, r#"{"repos":[{"name":"p","root":"/r"}]}"#).unwrap();
213        let reg = Registry::load(&path).unwrap();
214        assert_eq!(reg.get("p").unwrap().config, RepoProviderConfig::default());
215    }
216
217    #[test]
218    fn set_config_updates_and_round_trips() {
219        let dir = tempfile::tempdir().unwrap();
220        let path = dir.path().join("registry.json");
221        let mut reg = Registry::default();
222        reg.add("p", "/r").unwrap();
223        let cfg = RepoProviderConfig {
224            provider: Some("ollama".into()),
225            base_url: Some("http://host:11434".into()),
226            ..Default::default()
227        };
228        assert!(reg.set_config("p", cfg.clone()));
229        assert!(!reg.set_config("missing", cfg.clone()));
230        reg.save(&path).unwrap();
231        let loaded = Registry::load(&path).unwrap();
232        assert_eq!(loaded.get("p").unwrap().config, cfg);
233    }
234}