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