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