Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/src/services/app_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ fn write_stdout_payload<W: Write>(writer: &mut W, payload: &str) -> Result<(), C
})
}

fn write_error_diagnostic<W: Write>(writer: &mut W, error: &CliError) {
pub(crate) fn write_error_diagnostic<W: Write>(writer: &mut W, error: &CliError) {
write_error_diagnostic_with_color_policy(
writer,
error,
Expand Down
1 change: 1 addition & 0 deletions cli/src/services/command_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ pub fn default_runtime_command(name: &str) -> Option<RuntimeCommand> {
services::sync::NAME => Some(RuntimeCommand::Sync(services::sync::command::SyncCommand {
request: services::sync::SyncRequest {
format: services::output_format::OutputFormat::Text,
invocation: services::sync::SyncInvocation::Manual,
},
})),
_ => None,
Expand Down
64 changes: 61 additions & 3 deletions cli/src/services/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ impl FailureClass {
}
}

/// The typed origin of an automatic synchronization failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AutomaticSyncFailureKind {
Authentication,
ControlPlane,
Stream,
Runtime,
}

/// Catalog of expected, deliberately-explained failures presented to the user
/// as a friendly diagnostic instead of a technical error chain.
#[derive(Clone, Debug, Eq, PartialEq)]
Expand All @@ -59,14 +68,19 @@ pub enum UserError {
NotGitRemote {
remote_name: String,
},
AutomaticSyncFailed {
failure_kind: AutomaticSyncFailureKind,
reason: String,
},
}

impl UserError {
pub fn class(&self) -> FailureClass {
match self {
Self::NotAuthenticated | Self::NotGitRepository | Self::NotGitRemote { .. } => {
FailureClass::Runtime
}
Self::NotAuthenticated
| Self::NotGitRepository
| Self::NotGitRemote { .. }
| Self::AutomaticSyncFailed { .. } => FailureClass::Runtime,
}
}

Expand All @@ -76,6 +90,12 @@ impl UserError {
Self::NotAuthenticated => "auth.not_authenticated",
Self::NotGitRepository => "setup.not_git_repository",
Self::NotGitRemote { .. } => "setup.not_git_remote",
Self::AutomaticSyncFailed { failure_kind, .. } => match failure_kind {
AutomaticSyncFailureKind::Authentication => "sync.automatic.authentication_failed",
AutomaticSyncFailureKind::ControlPlane => "sync.automatic.control_plane_failed",
AutomaticSyncFailureKind::Stream => "sync.automatic.stream_failed",
AutomaticSyncFailureKind::Runtime => "sync.automatic.runtime_failed",
},
}
}

Expand All @@ -92,6 +112,44 @@ impl UserError {
Self::NotGitRemote { remote_name } => format!(
"The Git repository has no configured URL for remote '{remote_name}'. Please run `git remote add <remote> <url>`, then retry."
),
Self::AutomaticSyncFailed {
failure_kind: AutomaticSyncFailureKind::Authentication,
..
} => "Automatic synchronization failed: authentication is required. Run `sce auth login`, then manually retry with `sce sync`.".to_string(),
Self::AutomaticSyncFailed {
failure_kind: AutomaticSyncFailureKind::ControlPlane,
reason,
} => format!(
"Automatic synchronization failed: {reason}. Check control-plane connectivity and availability, then manually retry with `sce sync`."
),
Self::AutomaticSyncFailed {
failure_kind: AutomaticSyncFailureKind::Stream,
reason,
} => format!(
"Automatic synchronization failed: {reason}. Check Agent Trace data and connectivity, then manually retry with `sce sync`."
),
Self::AutomaticSyncFailed {
failure_kind: AutomaticSyncFailureKind::Runtime,
reason,
} => format!(
"Automatic synchronization failed: {reason}. Check the local repository and Agent Trace configuration, then manually retry with `sce sync`."
),
}
}

#[allow(dead_code)]
pub fn automatic_sync_failure_kind(&self) -> Option<AutomaticSyncFailureKind> {
match self {
Self::AutomaticSyncFailed { failure_kind, .. } => Some(*failure_kind),
Self::NotAuthenticated | Self::NotGitRepository | Self::NotGitRemote { .. } => None,
}
}

#[allow(dead_code)]
pub fn reason(&self) -> Option<&str> {
match self {
Self::AutomaticSyncFailed { reason, .. } => Some(reason),
Self::NotAuthenticated | Self::NotGitRepository | Self::NotGitRemote { .. } => None,
}
}
}
Expand Down
5 changes: 4 additions & 1 deletion cli/src/services/parse/command_runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,10 @@ fn convert_clap_command(command: cli_schema::Commands) -> Result<RuntimeCommand,
)),
cli_schema::Commands::Sync { format } => {
Ok(RuntimeCommand::Sync(services::sync::command::SyncCommand {
request: services::sync::SyncRequest { format },
request: services::sync::SyncRequest {
format,
invocation: services::sync::SyncInvocation::from_environment(),
},
}))
}
}
Expand Down
174 changes: 147 additions & 27 deletions cli/src/services/sync/auto_sync.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
//! Best-effort launcher for one-shot automatic Agent Trace synchronization.

use std::io;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::process::{Child, Command, Output, Stdio};

use crate::services::app_support;
use crate::services::error::{AutomaticSyncFailureKind, CliError, UserError};
use crate::services::sync::{AUTOMATIC_SYNC_INVOCATION_ENV, AUTOMATIC_SYNC_INVOCATION_VALUE};

const SYNC_ARGS: &[&str] = &["sync", "--format", "json"];

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum StdioMode {
Null,
Piped,
}

#[derive(Clone, Debug, Eq, PartialEq)]
Expand All @@ -19,6 +24,7 @@ struct AutoSyncCommand {
stdin: StdioMode,
stdout: StdioMode,
stderr: StdioMode,
environment: Vec<(String, String)>,
}

impl AutoSyncCommand {
Expand All @@ -29,59 +35,169 @@ impl AutoSyncCommand {
current_dir: repository_root.to_path_buf(),
stdin: StdioMode::Null,
stdout: StdioMode::Null,
stderr: StdioMode::Null,
stderr: StdioMode::Piped,
environment: vec![(
AUTOMATIC_SYNC_INVOCATION_ENV.to_string(),
AUTOMATIC_SYNC_INVOCATION_VALUE.to_string(),
)],
}
}
}

#[derive(Debug)]
enum AutoSyncLaunchError {
CurrentExecutable(io::Error),
Spawn(io::Error),
Wait(io::Error),
}

impl std::fmt::Display for AutoSyncLaunchError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CurrentExecutable(error) => {
write!(formatter, "failed to resolve current executable: {error}")
}
Self::Spawn(error) => write!(formatter, "failed to spawn automatic sync: {error}"),
Self::Wait(error) => write!(formatter, "failed to wait for automatic sync: {error}"),
}
}
}

impl std::error::Error for AutoSyncLaunchError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::CurrentExecutable(error) | Self::Spawn(error) | Self::Wait(error) => Some(error),
}
}
}

struct AutoSyncChild {
wait: Option<Box<dyn FnOnce() -> io::Result<Output>>>,
}

impl AutoSyncChild {
fn from_child(child: Child) -> Self {
Self {
wait: Some(Box::new(move || child.wait_with_output())),
}
}

fn wait_with_output(mut self) -> io::Result<Output> {
(self
.wait
.take()
.expect("automatic sync child wait callback must be present"))()
}
}

/// Launches the current executable to synchronize the repository in the
/// background. Launcher failures are intentionally ignored by the caller.
fn launcher_failure_diagnostic(error: AutoSyncLaunchError) -> CliError {
let reason = error.to_string();
CliError::user_with_source(
UserError::AutomaticSyncFailed {
failure_kind: AutomaticSyncFailureKind::Runtime,
reason,
},
error,
)
}

/// Launches the current executable to synchronize the repository and waits for
/// the one-shot child to reach terminal completion. Launcher failures are
/// reported on stderr but remain fail-open to the post-commit caller.
pub fn launch(repository_root: &Path) {
let _ = launch_with(repository_root, std::env::current_exe, spawn_command);
match launch_with(repository_root, std::env::current_exe, spawn_command) {
Ok(captured_stderr) => {
let mut stderr = io::stderr();
let _ = stderr.write_all(&captured_stderr);
}
Err(error) => {
let diagnostic = launcher_failure_diagnostic(error);
let mut stderr = io::stderr();
app_support::write_error_diagnostic(&mut stderr, &diagnostic);
}
}
}

fn launch_with<FCurrentExe, FSpawn>(
repository_root: &Path,
current_exe: FCurrentExe,
spawn: FSpawn,
) -> bool
) -> Result<Vec<u8>, AutoSyncLaunchError>
where
FCurrentExe: FnOnce() -> io::Result<PathBuf>,
FSpawn: FnOnce(AutoSyncCommand) -> io::Result<()>,
FSpawn: FnOnce(AutoSyncCommand) -> io::Result<AutoSyncChild>,
{
let Ok(executable) = current_exe() else {
return false;
};
let executable = current_exe().map_err(AutoSyncLaunchError::CurrentExecutable)?;

spawn(AutoSyncCommand::new(executable, repository_root)).is_ok()
let child = spawn(AutoSyncCommand::new(executable, repository_root))
.map_err(AutoSyncLaunchError::Spawn)?;
child
.wait_with_output()
.map(|output| output.stderr)
.map_err(AutoSyncLaunchError::Wait)
}

fn spawn_command(spec: AutoSyncCommand) -> io::Result<()> {
fn spawn_command(spec: AutoSyncCommand) -> io::Result<AutoSyncChild> {
let mut command = Command::new(spec.executable);
command
.args(spec.args)
.current_dir(spec.current_dir)
.envs(spec.environment)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
.stderr(Stdio::piped());

// Dropping Child does not wait for it; the spawned sync continues
// independently of the post-commit caller.
let _child = command.spawn()?;
Ok(())
Ok(AutoSyncChild::from_child(command.spawn()?))
}

#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Output;
use std::rc::Rc;

use super::{launch_with, AutoSyncCommand, StdioMode, SYNC_ARGS};
use super::{
launch_with, AutoSyncChild, AutoSyncCommand, StdioMode, AUTOMATIC_SYNC_INVOCATION_ENV,
AUTOMATIC_SYNC_INVOCATION_VALUE, SYNC_ARGS,
};

fn child_with_wait<F>(wait: F) -> AutoSyncChild
where
F: FnOnce() -> io::Result<Output> + 'static,
{
AutoSyncChild {
wait: Some(Box::new(wait)),
}
}

fn child_output(success: bool, stderr: &[u8]) -> Output {
Output {
status: exit_status(success),
stdout: Vec::new(),
stderr: stderr.to_vec(),
}
}

fn exit_status(success: bool) -> std::process::ExitStatus {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;

std::process::ExitStatus::from_raw(i32::from(!success))
}

#[cfg(windows)]
{
use std::os::windows::process::ExitStatusExt;

std::process::ExitStatus::from_raw(u32::from(!success))
}
}

#[test]
fn launch_builds_the_expected_detached_command() {
fn launch_builds_the_expected_command() {
let captured = Rc::new(RefCell::new(None));
let captured_by_spawn = Rc::clone(&captured);

Expand All @@ -90,11 +206,11 @@ mod tests {
|| Ok(PathBuf::from("/usr/local/bin/sce")),
move |command: AutoSyncCommand| {
*captured_by_spawn.borrow_mut() = Some(command);
Ok(())
Ok(child_with_wait(|| Ok(child_output(true, &[]))))
},
);

assert!(launched);
assert!(launched.is_ok());
assert_eq!(
captured.borrow().clone(),
Some(AutoSyncCommand {
Expand All @@ -103,7 +219,11 @@ mod tests {
current_dir: PathBuf::from("/repo/root"),
stdin: StdioMode::Null,
stdout: StdioMode::Null,
stderr: StdioMode::Null,
stderr: StdioMode::Piped,
environment: vec![(
AUTOMATIC_SYNC_INVOCATION_ENV.to_string(),
AUTOMATIC_SYNC_INVOCATION_VALUE.to_string(),
)],
})
);
}
Expand All @@ -116,13 +236,13 @@ mod tests {
let launched = launch_with(
Path::new("/repo/root"),
|| Err(io::Error::other("current executable unavailable")),
move |_| {
move |_| -> io::Result<AutoSyncChild> {
*spawn_called_by_spawn.borrow_mut() = true;
Ok(())
Ok(child_with_wait(|| Ok(child_output(true, &[]))))
},
);

assert!(!launched);
assert!(launched.is_err());
assert!(!*spawn_called.borrow());
}

Expand All @@ -134,6 +254,6 @@ mod tests {
|_| Err(io::Error::other("spawn unavailable")),
);

assert!(!launched);
assert!(launched.is_err());
}
}
Loading
Loading