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