Skip to main content

scv_client/
lib.rs

1//! What every local client of the SCV daemon needs, without depending on the
2//! server: the instance [`Layout`] (every path under `SCV_HOME`, and the
3//! instance's service unit name), the delegation-depth variable a delegated SCV
4//! inherits, the chat log ([`history`]), framed reading and writing
5//! ([`Connection`], [`read_frame`]),
6//! private instance files ([`fs::replace_private`]), [`Secret`] values
7//! that never print, byte-bounded text
8//! ([`text::utf8_prefix`]), and [`control`] for daemon management requests,
9//! which fail with a typed [`ControlError`].
10
11#![forbid(unsafe_code)]
12
13mod connection;
14pub mod fs;
15pub mod history;
16mod layout;
17mod secret;
18pub mod text;
19pub use connection::{Connection, read_frame, write_message};
20pub use layout::{Layout, Stray};
21pub use secret::Secret;
22
23use anyhow::Result;
24use scv_protocol::{
25    ClientMessage, DaemonCommand, DaemonStatus, ErrorCode, Frame, FrameDecoder, Overflow,
26    PROTOCOL_VERSION, ServerEvent,
27};
28use std::{fmt, path::Path, time::Duration};
29use tokio::{io::BufReader, net::UnixStream};
30
31/// Largest management reply, counting its line ending. Workbench actions
32/// reply with a snapshot the daemon may send up to its 8 MiB frame limit;
33/// a smaller cap here reports failure after the action already took effect.
34const MAX_CONTROL_FRAME_BYTES: usize = 8 * 1024 * 1024;
35
36/// Environment variable carrying a delegated process's depth; SCV sets it on
37/// every agent it starts.
38pub const DELEGATION_DEPTH_VARIABLE: &str = "SCV_DELEGATION_DEPTH";
39
40/// The delegation depth to declare in `session.start`: this process's own,
41/// when an SCV started it, so a delegated client cannot reset the count by
42/// connecting to a daemon.
43pub fn inherited_delegation_depth() -> Option<u32> {
44    parse_delegation_depth(std::env::var(DELEGATION_DEPTH_VARIABLE).ok().as_deref())
45}
46
47fn parse_delegation_depth(value: Option<&str>) -> Option<u32> {
48    value
49        .and_then(|value| value.trim().parse().ok())
50        .filter(|depth| *depth > 0)
51}
52
53/// Why a [`control`] request failed.
54#[derive(Debug)]
55#[non_exhaustive]
56pub enum ControlError {
57    /// No daemon accepted the connection: it is not running, or the socket
58    /// is stale. Nothing was sent.
59    Unavailable(std::io::Error),
60    /// The daemon refused the request. A daemon that predates a command
61    /// refuses it with [`ErrorCode::InvalidJson`], as a frame it cannot parse.
62    Server {
63        /// Why, as the daemon's stable code.
64        code: ErrorCode,
65        /// What went wrong, for people.
66        message: String,
67    },
68    /// No answer within the helper's time limit. The daemon may still carry
69    /// out a mutation; query status before retrying.
70    TimedOut,
71    /// The exchange broke off, or the daemon answered something this client
72    /// does not understand. A mutation's outcome is unknown.
73    Protocol(String),
74}
75
76impl fmt::Display for ControlError {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        match self {
79            Self::Unavailable(_) => formatter
80                .write_str("SCV daemon unavailable; start it with `scv start` or `scv run`"),
81            Self::Server { message, .. } => formatter.write_str(message),
82            Self::TimedOut => formatter
83                .write_str("SCV management request timed out; query status before retrying"),
84            Self::Protocol(message) => formatter.write_str(message),
85        }
86    }
87}
88
89impl std::error::Error for ControlError {
90    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
91        match self {
92            Self::Unavailable(error) => Some(error),
93            _ => None,
94        }
95    }
96}
97
98/// How long a management exchange may take. A command that can stop a
99/// running account waits for it to stop, and an email account finishes a
100/// mail action under way first, for up to 65 seconds; a workbench action
101/// that may run `git` on worktrees (each command up to 30 seconds), wait
102/// for an agent to accept steering (up to 30 seconds), or stop a live
103/// agent server and run the project Rust preflight before it opens an
104/// agent's terminal (up to 28 seconds), gets 90.
105pub fn control_timeout(command: &DaemonCommand) -> Duration {
106    match command {
107        DaemonCommand::Reload
108        | DaemonCommand::ChannelSet { .. }
109        | DaemonCommand::ChannelLogout { .. }
110        | DaemonCommand::WorkbenchStart { .. }
111        | DaemonCommand::WorkbenchResume { .. }
112        | DaemonCommand::WorkbenchSend { .. }
113        | DaemonCommand::Worktrees
114        | DaemonCommand::WorktreeRemove { .. }
115        | DaemonCommand::Attach { .. } => Duration::from_secs(90),
116        _ => Duration::from_secs(20),
117    }
118}
119
120/// A bounded management exchange. Never retries mutations on ambiguous failure.
121pub async fn control(path: &Path, command: DaemonCommand) -> Result<DaemonStatus, ControlError> {
122    let broken = |error: std::io::Error| ControlError::Protocol(format!("{error}"));
123    tokio::time::timeout(control_timeout(&command), async {
124        let stream = UnixStream::connect(path)
125            .await
126            .map_err(ControlError::Unavailable)?;
127        let (reader, writer) = stream.into_split();
128        let mut connection = Connection::new(
129            BufReader::new(reader),
130            writer,
131            FrameDecoder::new(MAX_CONTROL_FRAME_BYTES, Overflow::Stop),
132        );
133        for message in [
134            ClientMessage::initialize("init", "scv-control"),
135            ClientMessage::DaemonControl {
136                request_id: "control".into(),
137                command,
138            },
139        ] {
140            connection.send(&message).await.map_err(broken)?;
141            let bytes = match connection.read().await.map_err(broken)? {
142                Frame::Line(bytes) => bytes,
143                Frame::TooLarge => {
144                    return Err(ControlError::Protocol(
145                        "SCV status exceeds frame limit".into(),
146                    ));
147                }
148                Frame::End | Frame::Truncated(_) => {
149                    return Err(ControlError::Protocol(
150                        "SCV daemon closed the management connection".into(),
151                    ));
152                }
153            };
154            let event = serde_json::from_slice::<ServerEvent>(&bytes)
155                .map_err(|error| ControlError::Protocol(format!("{error}")))?;
156            match event {
157                ServerEvent::Initialized {
158                    protocol_version: PROTOCOL_VERSION,
159                    ..
160                } if matches!(message, ClientMessage::Initialize { .. }) => {}
161                ServerEvent::DaemonStatus { status, .. } => return Ok(status),
162                ServerEvent::Error { code, message, .. } => {
163                    return Err(ControlError::Server { code, message });
164                }
165                _ => {
166                    return Err(ControlError::Protocol(
167                        "unexpected SCV management response; upgrade/restart the daemon".into(),
168                    ));
169                }
170            }
171        }
172        Err(ControlError::Protocol("SCV daemon omitted status".into()))
173    })
174    .await
175    .unwrap_or(Err(ControlError::TimedOut))
176}
177
178#[cfg(test)]
179mod tests;