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//! - `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;