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, task
17//! subscriptions, saved session queues, conversation markers, review
18//! journals, import records, the model and effort values each agent offers,
19//! channel delivery state and 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 /// Task subscriptions: one record per bound chat thread
189 /// (`<id>.json`), beside the job ledger. A subscription names a job
190 /// that already exists; it is not a job and not part of [`Self::jobs`].
191 pub fn subscriptions(&self) -> PathBuf {
192 self.state().join("subscriptions")
193 }
194
195 /// Queues of sessions that keep theirs (`persist_queue`), one
196 /// `<session>.json` each, for a session started after a restart.
197 pub fn session_queues(&self) -> PathBuf {
198 self.state().join("sessions")
199 }
200
201 /// Git worktrees SCV created for jobs, under
202 /// `<repository>-<digest>/<name>`.
203 pub fn worktrees(&self) -> PathBuf {
204 self.home.join("worktrees")
205 }
206
207 /// Journals of reviewed delegated jobs, one JSONL file per review, kept
208 /// 30 days.
209 pub fn reviews(&self) -> PathBuf {
210 self.state().join("reviews")
211 }
212
213 /// The model and effort values `agent`'s ACP server last offered.
214 pub fn agent_options(&self, agent: &str) -> PathBuf {
215 self.state()
216 .join("agent-options")
217 .join(format!("{agent}.json"))
218 }
219
220 /// What each `scv agents import` copied, and from where.
221 pub fn imports(&self) -> PathBuf {
222 self.state().join("imports")
223 }
224
225 /// One channel's delivery state and account locks.
226 pub fn channel_state(&self, channel: &str) -> PathBuf {
227 self.state().join("channels").join(channel)
228 }
229
230 /// One mail account's private working files: the empty working
231 /// directory its tool-free sessions start in and, with mail actions on,
232 /// each action's content and the audit log. Removed at logout.
233 pub fn mail_state(&self, account: &str) -> PathBuf {
234 self.state().join("mail").join(account)
235 }
236
237 /// Files chat users sent, under `<channel>/<account>`, and copies of files
238 /// the model sends back, under `outbox`.
239 pub fn media(&self) -> PathBuf {
240 self.state().join("media")
241 }
242
243 /// Copies of files the model attached, waiting to be sent to a chat.
244 pub fn outbox(&self) -> PathBuf {
245 self.media().join("outbox")
246 }
247
248 /// A planned restart in progress.
249 pub fn update_plan(&self) -> PathBuf {
250 self.state().join("update.json")
251 }
252
253 /// The chat the account owner last wrote from.
254 pub fn last_owner(&self) -> PathBuf {
255 self.state().join("last-owner.json")
256 }
257
258 /// Present while a daemon runs; one left behind means it did not shut
259 /// down cleanly.
260 pub fn daemon_marker(&self) -> PathBuf {
261 self.state().join("daemon.json")
262 }
263
264 /// Serializes SCV's own edits of `config.toml`.
265 pub fn config_lock(&self) -> PathBuf {
266 self.state().join("config.lock")
267 }
268
269 /// Entries of the home that SCV does not read, sorted by name.
270 pub fn strays(&self) -> Result<Vec<Stray>> {
271 let entries = match std::fs::read_dir(&self.home) {
272 Ok(entries) => entries,
273 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
274 Err(error) => {
275 return Err(error).with_context(|| format!("read {}", self.home.display()));
276 }
277 };
278 let mut strays = Vec::new();
279 for entry in entries {
280 let name = entry?.file_name();
281 let Some(text) = name.to_str() else {
282 strays.push(Stray {
283 path: self.home.join(&name),
284 legacy: false,
285 });
286 continue;
287 };
288 if ENTRIES.contains(&text) {
289 continue;
290 }
291 strays.push(Stray {
292 path: self.home.join(text),
293 legacy: LEGACY.contains(&text),
294 });
295 }
296 strays.sort_by(|a, b| a.path.cmp(&b.path));
297 Ok(strays)
298 }
299}
300
301/// `path` resolved as an instance home: canonical when it exists, otherwise
302/// made absolute against the working directory.
303fn resolve(path: PathBuf) -> Result<PathBuf> {
304 if path.exists() {
305 Ok(std::fs::canonicalize(&path).unwrap_or(path))
306 } else if path.is_absolute() {
307 Ok(path)
308 } else {
309 Ok(std::env::current_dir()
310 .context("cannot determine SCV instance home")?
311 .join(path))
312 }
313}
314
315#[cfg(test)]
316mod tests;