commit 05960f02354cff20a71159e491482a8dc9719397
parent 92d0edf6f4f802070d1678e621996df6007e8f1b
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 16:38:00 +0000
error: surface refresh attempt ids on failure
Diffstat:
4 files changed, 226 insertions(+), 13 deletions(-)
diff --git a/src/discovery.rs b/src/discovery.rs
@@ -639,9 +639,12 @@ pub async fn refresh_nip89(
return Err(MycError::DiscoveryFetchUnavailable {
relay_count,
details,
- });
+ }
+ .with_discovery_refresh_attempt_id(attempt_id));
+ }
+ Err(error) => {
+ return Err(error.with_discovery_refresh_attempt_id(attempt_id));
}
- Err(error) => return Err(error),
};
let relay_states = build_relay_diffs(&local_handler, &fetched.relay_states);
let relay_summary = summarize_relay_diffs(&relay_states);
@@ -681,10 +684,13 @@ pub async fn refresh_nip89(
)
.with_attempt_id(attempt_id.clone()),
);
- return Err(MycError::InvalidOperation(format!(
- "one or more discovery relays were unavailable; rerun `discovery refresh-nip89 --force` to override: {}",
- relay_summary.unavailable_relays.join(", ")
- )));
+ return Err(
+ MycError::InvalidOperation(format!(
+ "one or more discovery relays were unavailable; rerun `discovery refresh-nip89 --force` to override: {}",
+ relay_summary.unavailable_relays.join(", ")
+ ))
+ .with_discovery_refresh_attempt_id(attempt_id),
+ );
}
if !relay_summary.conflicted_relays.is_empty() && !force {
@@ -701,13 +707,17 @@ pub async fn refresh_nip89(
)
.with_attempt_id(attempt_id.clone()),
);
- return Err(MycError::InvalidOperation(
- "live discovery handler state is conflicted; rerun `discovery refresh-nip89 --force` to override"
- .to_owned(),
- ));
+ return Err(
+ MycError::InvalidOperation(
+ "live discovery handler state is conflicted; rerun `discovery refresh-nip89 --force` to override"
+ .to_owned(),
+ )
+ .with_discovery_refresh_attempt_id(attempt_id),
+ );
}
- let refresh_relays = select_refresh_relays(&context, &relay_states, force)?;
+ let refresh_relays = select_refresh_relays(&context, &relay_states, force)
+ .map_err(|error| error.with_discovery_refresh_attempt_id(attempt_id.clone()))?;
if refresh_relays.is_empty() {
let repair_results = build_repair_results(&context, &relay_states, &[], None, None);
@@ -828,9 +838,9 @@ pub async fn refresh_nip89(
repair_summary.skipped
),
)
- .with_attempt_id(attempt_id),
+ .with_attempt_id(attempt_id.clone()),
);
- return Err(error);
+ return Err(error.with_discovery_refresh_attempt_id(attempt_id));
}
}
}
diff --git a/src/error.rs b/src/error.rs
@@ -77,6 +77,12 @@ pub enum MycError {
"failed to fetch discovery state from all configured relays ({relay_count}): {details}"
)]
DiscoveryFetchUnavailable { relay_count: usize, details: String },
+ #[error("discovery refresh attempt {attempt_id} failed: {source}")]
+ DiscoveryRefreshFailed {
+ attempt_id: String,
+ #[source]
+ source: Box<MycError>,
+ },
#[error(transparent)]
Identity(#[from] IdentityError),
#[error(transparent)]
@@ -113,9 +119,27 @@ pub enum MycError {
}
impl MycError {
+ pub fn with_discovery_refresh_attempt_id(self, attempt_id: impl Into<String>) -> Self {
+ match self {
+ Self::DiscoveryRefreshFailed { .. } => self,
+ source => Self::DiscoveryRefreshFailed {
+ attempt_id: attempt_id.into(),
+ source: Box::new(source),
+ },
+ }
+ }
+
+ pub fn discovery_refresh_attempt_id(&self) -> Option<&str> {
+ match self {
+ Self::DiscoveryRefreshFailed { attempt_id, .. } => Some(attempt_id.as_str()),
+ _ => None,
+ }
+ }
+
pub fn publish_rejection_details(&self) -> Option<&str> {
match self {
Self::PublishRejected { details, .. } => Some(details.as_str()),
+ Self::DiscoveryRefreshFailed { source, .. } => source.publish_rejection_details(),
_ => None,
}
}
@@ -127,6 +151,7 @@ impl MycError {
acknowledged_relay_count,
..
} => Some((*relay_count, *acknowledged_relay_count)),
+ Self::DiscoveryRefreshFailed { source, .. } => source.publish_rejection_counts(),
_ => None,
}
}
@@ -136,7 +161,36 @@ impl MycError {
Self::PublishRejected {
rejected_relays, ..
} => Some(rejected_relays.as_slice()),
+ Self::DiscoveryRefreshFailed { source, .. } => source.publish_rejected_relays(),
_ => None,
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::MycError;
+
+ #[test]
+ fn discovery_refresh_wrapper_preserves_attempt_id_and_publish_details() {
+ let wrapped = MycError::PublishRejected {
+ operation: "discovery refresh".to_owned(),
+ relay_count: 2,
+ acknowledged_relay_count: 0,
+ details: "relay-a: blocked".to_owned(),
+ rejected_relays: vec!["wss://relay-a.example.com".to_owned()],
+ }
+ .with_discovery_refresh_attempt_id("attempt-1");
+
+ assert_eq!(wrapped.discovery_refresh_attempt_id(), Some("attempt-1"));
+ assert_eq!(
+ wrapped.publish_rejection_details(),
+ Some("relay-a: blocked")
+ );
+ assert_eq!(wrapped.publish_rejection_counts(), Some((2, 0)));
+ assert_eq!(
+ wrapped.publish_rejected_relays(),
+ Some(["wss://relay-a.example.com".to_owned()].as_slice())
+ );
+ }
+}
diff --git a/src/main.rs b/src/main.rs
@@ -4,6 +4,12 @@
async fn main() {
if let Err(err) = myc::run_cli().await {
eprintln!("myc: {err}");
+ if let Some(attempt_id) = err.discovery_refresh_attempt_id() {
+ eprintln!("myc: discovery repair attempt id: {attempt_id}");
+ eprintln!(
+ "myc: inspect with `myc audit discovery-repair-attempt --attempt-id {attempt_id}`"
+ );
+ }
std::process::exit(1);
}
}
diff --git a/tests/discovery_cli.rs b/tests/discovery_cli.rs
@@ -381,6 +381,12 @@ fn run_myc(config_path: &Path, args: &[&str]) -> TestResult<Output> {
.output()?)
}
+fn extract_discovery_attempt_id(stderr: &str) -> Option<&str> {
+ stderr
+ .lines()
+ .find_map(|line| line.strip_prefix("myc: discovery repair attempt id: "))
+}
+
fn unavailable_relay_url() -> TestResult<String> {
let listener = StdTcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
@@ -686,6 +692,31 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> {
"unexpected refresh stderr: {}",
String::from_utf8_lossy(&refresh.stderr)
);
+ let refresh_stderr = String::from_utf8_lossy(&refresh.stderr);
+ let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id");
+ let attempt = run_myc(
+ &config_path,
+ &[
+ "audit",
+ "discovery-repair-attempt",
+ "--attempt-id",
+ attempt_id,
+ ],
+ )?;
+ assert!(
+ attempt.status.success(),
+ "discovery-repair-attempt failed: {}",
+ String::from_utf8_lossy(&attempt.stderr)
+ );
+ let attempt_output: Value = serde_json::from_slice(&attempt.stdout)?;
+ assert_eq!(
+ attempt_output["attempt_id"],
+ Value::String(attempt_id.to_owned())
+ );
+ assert_eq!(
+ attempt_output["refresh_outcome"],
+ Value::String("conflicted".to_owned())
+ );
let forced_refresh = run_myc(&config_path, &["discovery", "refresh-nip89", "--force"])?;
assert!(
@@ -803,6 +834,93 @@ async fn refresh_reports_partial_repair_and_audit_summary_through_the_cli() -> T
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn failed_refresh_publish_surfaces_attempt_id_and_exact_audit_lookup() -> TestResult<()> {
+ let relay = TestRelay::spawn().await?;
+ let temp = tempfile::tempdir()?;
+ let config_path = temp.path().join("config.toml");
+ let state_dir = temp.path().join("state");
+ let signer_identity_path = temp.path().join("signer.json");
+ let user_identity_path = temp.path().join("user.json");
+ let app_identity_path = temp.path().join("app.json");
+ let app_identity = RadrootsIdentity::from_secret_key_str(
+ "3333333333333333333333333333333333333333333333333333333333333333",
+ )?;
+
+ write_identity(
+ &signer_identity_path,
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ );
+ write_identity(
+ &user_identity_path,
+ "2222222222222222222222222222222222222222222222222222222222222222",
+ );
+ app_identity.save_json(&app_identity_path)?;
+ write_config(
+ &config_path,
+ &state_dir,
+ &signer_identity_path,
+ &user_identity_path,
+ &app_identity_path,
+ &[relay.url()],
+ );
+
+ relay
+ .queue_publish_outcomes(app_identity.public_key(), &[false])
+ .await;
+
+ let refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?;
+ assert!(
+ !refresh.status.success(),
+ "refresh-nip89 unexpectedly succeeded: {}",
+ String::from_utf8_lossy(&refresh.stdout)
+ );
+ let refresh_stderr = String::from_utf8_lossy(&refresh.stderr);
+ assert!(
+ refresh_stderr.contains("Nostr publish failed"),
+ "unexpected refresh stderr: {refresh_stderr}"
+ );
+ let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id");
+
+ let attempt = run_myc(
+ &config_path,
+ &[
+ "audit",
+ "discovery-repair-attempt",
+ "--attempt-id",
+ attempt_id,
+ ],
+ )?;
+ assert!(
+ attempt.status.success(),
+ "discovery-repair-attempt failed: {}",
+ String::from_utf8_lossy(&attempt.stderr)
+ );
+ let attempt_output: Value = serde_json::from_slice(&attempt.stdout)?;
+ assert_eq!(
+ attempt_output["attempt_id"],
+ Value::String(attempt_id.to_owned())
+ );
+ assert_eq!(
+ attempt_output["refresh_outcome"],
+ Value::String("rejected".to_owned())
+ );
+ assert_eq!(
+ attempt_output["aggregate_publish_outcome"],
+ Value::String("rejected".to_owned())
+ );
+ assert_eq!(
+ attempt_output["repair_summary"]["failed"],
+ Value::from(1_u64)
+ );
+ assert_eq!(
+ attempt_output["remaining_repair_relays"],
+ Value::Array(vec![Value::String(relay.url().to_owned())])
+ );
+
+ Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn discovery_repair_attempt_commands_correlate_multiple_refresh_runs() -> TestResult<()> {
let relay_a = TestRelay::spawn().await?;
let relay_b = TestRelay::spawn().await?;
@@ -1189,6 +1307,31 @@ async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_th
"unexpected refresh stderr: {}",
String::from_utf8_lossy(&refresh.stderr)
);
+ let refresh_stderr = String::from_utf8_lossy(&refresh.stderr);
+ let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id");
+ let attempt = run_myc(
+ &config_path,
+ &[
+ "audit",
+ "discovery-repair-attempt",
+ "--attempt-id",
+ attempt_id,
+ ],
+ )?;
+ assert!(
+ attempt.status.success(),
+ "discovery-repair-attempt failed: {}",
+ String::from_utf8_lossy(&attempt.stderr)
+ );
+ let attempt_output: Value = serde_json::from_slice(&attempt.stdout)?;
+ assert_eq!(
+ attempt_output["attempt_id"],
+ Value::String(attempt_id.to_owned())
+ );
+ assert_eq!(
+ attempt_output["refresh_outcome"],
+ Value::String("unavailable".to_owned())
+ );
let forced_refresh = run_myc(&config_path, &["discovery", "refresh-nip89", "--force"])?;
assert!(