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