Skip to main content

scv_client/
layout.rs

1//! Where an SCV instance keeps everything, in one place.
2//!
3//! An instance home (`SCV_HOME`, default `~/.scv`) holds exactly:
4//!
5//! - `config.toml`: every setting a person edits, including channel accounts;
6//! - `credentials/`: sign-ins SCV writes itself (channel logins);
7//! - `agents/<name>/`: the private homes of delegated agent CLIs, which keep
8//!   their own sign-ins and configuration there;
9//! - `skills/`: the user's SCV skills;
10//! - `state/`: runtime data SCV writes: the daemon socket and lock, delegated
11//!   run records, conversation markers, import records, channel delivery
12//!   state and locks, and chat media.
13//!
14//! Anything else in the home is not read by SCV; [`Layout::strays`] lists it.
15//!
16//! A process selects its instance once, with [`Layout::from_env`], and hands
17//! the `Layout` to everything that needs a path; the environment only carries
18//! the selection on to child processes.
19
20use anyhow::{Context, Result};
21use sha2::{Digest, Sha256};
22use std::path::{Path, PathBuf};
23
24/// Top-level entries of an instance home, in display order.
25pub(crate) const ENTRIES: [&str; 5] = ["config.toml", "credentials", "agents", "skills", "state"];
26
27/// Paths earlier releases used, which SCV no longer reads.
28const LEGACY: [&str; 7] = [
29    "adapters",
30    "channels",
31    "run",
32    "server.sock",
33    "server.lock",
34    "clawbot",
35    "clawbot.toml",
36];
37
38/// The paths of one SCV instance.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Layout {
41    home: PathBuf,
42    /// Selected by default (`~/.scv`) rather than by `SCV_HOME`.
43    default: bool,
44}
45
46/// Something in an instance home that SCV does not read.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Stray {
49    pub path: PathBuf,
50    /// A path an earlier SCV release used, rather than an unknown file.
51    pub legacy: bool,
52}
53
54impl Layout {
55    /// The instance at `home`, selected explicitly as `SCV_HOME` selects one.
56    pub fn new(home: impl Into<PathBuf>) -> Self {
57        Self {
58            home: home.into(),
59            default: false,
60        }
61    }
62
63    /// The instance selected by `SCV_HOME`, or `~/.scv`, with its home
64    /// resolved: canonical when it exists, otherwise made absolute. Service
65    /// unit names and delegation records hash this path, so every process of
66    /// an instance must resolve it the same way.
67    pub fn from_env() -> Result<Self> {
68        let selected = std::env::var_os("SCV_HOME").map(PathBuf::from);
69        let default = selected.is_none();
70        let home = selected
71            .or_else(|| dirs::home_dir().map(|path| path.join(".scv")))
72            .context("cannot determine SCV_HOME")?;
73        Ok(Self {
74            home: resolve(home)?,
75            default,
76        })
77    }
78
79    pub fn home(&self) -> &Path {
80        &self.home
81    }
82
83    /// Whether this is the default instance, which `SCV_HOME` did not select.
84    pub fn is_default(&self) -> bool {
85        self.default
86    }
87
88    /// The instance's systemd user unit: `scv.service` for the default
89    /// instance, otherwise `scv-<hash of the home>.service`, so instances
90    /// never share a unit and a release finds the unit an earlier one wrote.
91    pub fn service_name(&self) -> String {
92        if self.default {
93            return "scv.service".into();
94        }
95        let digest = Sha256::digest(self.home.to_string_lossy().as_bytes());
96        let suffix = digest[..8]
97            .iter()
98            .map(|byte| format!("{byte:02x}"))
99            .collect::<String>();
100        format!("scv-{suffix}.service")
101    }
102
103    /// The settings file a person edits.
104    pub fn config(&self) -> PathBuf {
105        self.home.join("config.toml")
106    }
107
108    /// Sign-ins SCV writes itself.
109    pub fn credentials(&self) -> PathBuf {
110        self.home.join("credentials")
111    }
112
113    /// One channel's account credentials, `<account>.json` each.
114    pub fn channel_credentials(&self, channel: &str) -> PathBuf {
115        self.credentials().join(channel)
116    }
117
118    /// The private homes of delegated agent CLIs.
119    pub fn agents(&self) -> PathBuf {
120        self.home.join("agents")
121    }
122
123    pub fn agent_home(&self, agent: &str) -> PathBuf {
124        self.agents().join(agent)
125    }
126
127    pub fn skills(&self) -> PathBuf {
128        self.home.join("skills")
129    }
130
131    /// Runtime data SCV writes and reads back; never edited by hand.
132    pub fn state(&self) -> PathBuf {
133        self.home.join("state")
134    }
135
136    pub fn socket(&self) -> PathBuf {
137        self.state().join("server.sock")
138    }
139
140    /// Records of running delegated agents.
141    pub fn delegations(&self) -> PathBuf {
142        self.state().join("delegations")
143    }
144
145    /// Markers of live delegated conversations, for `scv agents gc`.
146    pub fn conversations(&self) -> PathBuf {
147        self.state().join("conversations")
148    }
149
150    /// What each `scv agents import` copied, and from where.
151    pub fn imports(&self) -> PathBuf {
152        self.state().join("imports")
153    }
154
155    /// One channel's delivery state and account locks.
156    pub fn channel_state(&self, channel: &str) -> PathBuf {
157        self.state().join("channels").join(channel)
158    }
159
160    /// Files chat users sent, under `<channel>/<account>`, and copies of files
161    /// the model sends back, under `outbox`.
162    pub fn media(&self) -> PathBuf {
163        self.state().join("media")
164    }
165
166    /// Copies of files the model attached, waiting to be sent to a chat.
167    pub fn outbox(&self) -> PathBuf {
168        self.media().join("outbox")
169    }
170
171    /// A planned restart in progress.
172    pub fn update_plan(&self) -> PathBuf {
173        self.state().join("update.json")
174    }
175
176    /// The chat the account owner last wrote from.
177    pub fn last_owner(&self) -> PathBuf {
178        self.state().join("last-owner.json")
179    }
180
181    /// Present while a daemon runs; one left behind means it did not shut
182    /// down cleanly.
183    pub fn daemon_marker(&self) -> PathBuf {
184        self.state().join("daemon.json")
185    }
186
187    /// Serializes SCV's own edits of `config.toml`.
188    pub fn config_lock(&self) -> PathBuf {
189        self.state().join("config.lock")
190    }
191
192    /// Entries of the home that SCV does not read, sorted by name.
193    pub fn strays(&self) -> Result<Vec<Stray>> {
194        let entries = match std::fs::read_dir(&self.home) {
195            Ok(entries) => entries,
196            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
197            Err(error) => {
198                return Err(error).with_context(|| format!("read {}", self.home.display()));
199            }
200        };
201        let mut strays = Vec::new();
202        for entry in entries {
203            let name = entry?.file_name();
204            let Some(text) = name.to_str() else {
205                strays.push(Stray {
206                    path: self.home.join(&name),
207                    legacy: false,
208                });
209                continue;
210            };
211            if ENTRIES.contains(&text) {
212                continue;
213            }
214            strays.push(Stray {
215                path: self.home.join(text),
216                legacy: LEGACY.contains(&text),
217            });
218        }
219        strays.sort_by(|a, b| a.path.cmp(&b.path));
220        Ok(strays)
221    }
222}
223
224/// `path` resolved as an instance home: canonical when it exists, otherwise
225/// made absolute against the working directory.
226fn resolve(path: PathBuf) -> Result<PathBuf> {
227    if path.exists() {
228        Ok(std::fs::canonicalize(&path).unwrap_or(path))
229    } else if path.is_absolute() {
230        Ok(path)
231    } else {
232        Ok(std::env::current_dir()
233            .context("cannot determine SCV instance home")?
234            .join(path))
235    }
236}
237
238#[cfg(test)]
239mod tests;