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, 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;