commit 499e51644dd4408a7edddd894e799a5a6e456021
parent 687db8274eafba7aadd2b3f8f9863546d4db6791
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 17:00:37 +0000
audit: persist blocked discovery repair state
Diffstat:
5 files changed, 295 insertions(+), 9 deletions(-)
diff --git a/src/audit.rs b/src/audit.rs
@@ -54,6 +54,12 @@ pub struct MycOperationAuditRecord {
pub request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attempt_id: Option<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub planned_repair_relays: Vec<String>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub blocked_relays: Vec<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub blocked_reason: Option<String>,
pub relay_count: usize,
pub acknowledged_relay_count: usize,
pub relay_outcome_summary: String,
@@ -83,6 +89,9 @@ impl MycOperationAuditRecord {
connection_id: connection_id.map(ToString::to_string),
request_id: request_id.map(ToOwned::to_owned),
attempt_id: None,
+ planned_repair_relays: Vec::new(),
+ blocked_relays: Vec::new(),
+ blocked_reason: None,
relay_count,
acknowledged_relay_count,
relay_outcome_summary: relay_outcome_summary.into(),
@@ -98,6 +107,21 @@ impl MycOperationAuditRecord {
self.attempt_id = Some(attempt_id.into());
self
}
+
+ pub fn with_planned_repair_relays(mut self, planned_repair_relays: Vec<String>) -> Self {
+ self.planned_repair_relays = planned_repair_relays;
+ self
+ }
+
+ pub fn with_blocked_relays(
+ mut self,
+ blocked_reason: impl Into<String>,
+ blocked_relays: Vec<String>,
+ ) -> Self {
+ self.blocked_reason = Some(blocked_reason.into());
+ self.blocked_relays = blocked_relays;
+ self
+ }
}
impl MycOperationAuditStore {
@@ -542,7 +566,12 @@ mod tests {
0,
"first attempt rejected",
)
- .with_attempt_id("attempt-1"),
+ .with_attempt_id("attempt-1")
+ .with_planned_repair_relays(vec!["wss://relay-a.example.com".to_owned()])
+ .with_blocked_relays(
+ "unavailable_relays",
+ vec!["wss://relay-b.example.com".to_owned()],
+ ),
)
.expect("append first attempt");
store
@@ -585,6 +614,18 @@ mod tests {
.all(|record| record.attempt_id.as_deref() == Some("attempt-1"))
);
assert_eq!(
+ attempt_records[0].planned_repair_relays,
+ vec!["wss://relay-a.example.com".to_owned()]
+ );
+ assert_eq!(
+ attempt_records[0].blocked_relays,
+ vec!["wss://relay-b.example.com".to_owned()]
+ );
+ assert_eq!(
+ attempt_records[0].blocked_reason.as_deref(),
+ Some("unavailable_relays")
+ );
+ assert_eq!(
store
.latest_attempt_id_for_operation(MycOperationAuditKind::DiscoveryHandlerRefresh)
.expect("latest attempt"),
diff --git a/src/cli.rs b/src/cli.rs
@@ -240,6 +240,10 @@ pub struct MycDiscoveryRepairAttemptSummaryOutput {
#[serde(skip_serializing_if = "Option::is_none")]
pub aggregate_publish_relay_outcome_summary: Option<String>,
pub repair_summary: MycDiscoveryRepairSummary,
+ pub planned_repair_relays: Vec<String>,
+ pub blocked_relays: Vec<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub blocked_reason: Option<String>,
pub failed_relays: Vec<String>,
pub remaining_repair_relays: Vec<String>,
}
@@ -705,6 +709,10 @@ impl MycDiscoveryRepairAttemptSummaryOutput {
(record.operation == MycOperationAuditKind::DiscoveryHandlerRefresh)
.then_some(record.outcome)
});
+ let refresh_record = records
+ .iter()
+ .rev()
+ .find(|record| record.operation == MycOperationAuditKind::DiscoveryHandlerRefresh);
let publish_record = records
.iter()
.rev()
@@ -731,6 +739,27 @@ impl MycDiscoveryRepairAttemptSummaryOutput {
}
failed_relays.sort();
failed_relays.dedup();
+ let planned_repair_relays = refresh_record
+ .map(|record| record.planned_repair_relays.clone())
+ .unwrap_or_default();
+ let blocked_relays = refresh_record
+ .map(|record| record.blocked_relays.clone())
+ .unwrap_or_default();
+ let blocked_reason = refresh_record.and_then(|record| record.blocked_reason.clone());
+ let remaining_repair_relays = if !failed_relays.is_empty() {
+ failed_relays.clone()
+ } else if matches!(
+ refresh_outcome,
+ Some(
+ MycOperationAuditOutcome::Unavailable
+ | MycOperationAuditOutcome::Conflicted
+ | MycOperationAuditOutcome::Rejected
+ )
+ ) {
+ planned_repair_relays.clone()
+ } else {
+ Vec::new()
+ };
Ok(Self {
attempt_id: attempt_id.to_owned(),
@@ -746,8 +775,11 @@ impl MycDiscoveryRepairAttemptSummaryOutput {
aggregate_publish_relay_outcome_summary: publish_record
.map(|record| record.relay_outcome_summary.clone()),
repair_summary,
+ planned_repair_relays,
+ blocked_relays,
+ blocked_reason,
failed_relays: failed_relays.clone(),
- remaining_repair_relays: failed_relays,
+ remaining_repair_relays,
})
}
}
diff --git a/src/discovery.rs b/src/discovery.rs
@@ -216,6 +216,12 @@ pub struct MycRefreshedNip89Output {
pub published: Option<MycPublishedNip89Output>,
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct MycDiscoveryRefreshPlan {
+ selected_relays: Vec<RadrootsNostrRelayUrl>,
+ planned_repair_relays: Vec<String>,
+}
+
#[derive(Debug, Clone)]
struct MycSourcedLiveNip89Event {
source_relay: String,
@@ -611,6 +617,7 @@ pub async fn refresh_nip89(
) -> Result<MycRefreshedNip89Output, MycError> {
let context = MycDiscoveryContext::from_runtime(runtime)?;
let attempt_id = RadrootsNostrSignerRequestId::new_v7().into_string();
+ let configured_publish_relays = relay_urls_to_strings(context.publish_relays());
let local_handler = context.render_normalized_nip89_handler();
let fetched = match fetch_live_nip89_state_for_runtime(
runtime,
@@ -634,7 +641,8 @@ pub async fn refresh_nip89(
0,
details.clone(),
)
- .with_attempt_id(attempt_id.clone()),
+ .with_attempt_id(attempt_id.clone())
+ .with_blocked_relays("all_relays_unavailable", configured_publish_relays.clone()),
);
return Err(MycError::DiscoveryFetchUnavailable {
relay_count,
@@ -654,6 +662,8 @@ pub async fn refresh_nip89(
let compare_request_id = latest_live_event_id(&live_groups);
let compare_summary =
describe_compare_status(status, &differing_fields, &live_groups, &relay_summary);
+ let blocked_refresh_plan = build_refresh_plan(&context, &relay_states, true)
+ .map_err(|error| error.with_discovery_refresh_attempt_id(attempt_id.clone()))?;
runtime.record_operation_audit(
&MycOperationAuditRecord::new(
@@ -682,7 +692,12 @@ pub async fn refresh_nip89(
relay_summary.unavailable_relays.join(", ")
),
)
- .with_attempt_id(attempt_id.clone()),
+ .with_attempt_id(attempt_id.clone())
+ .with_planned_repair_relays(blocked_refresh_plan.planned_repair_relays.clone())
+ .with_blocked_relays(
+ "unavailable_relays",
+ relay_summary.unavailable_relays.clone(),
+ ),
);
return Err(
MycError::InvalidOperation(format!(
@@ -705,7 +720,12 @@ pub async fn refresh_nip89(
"live discovery handler state is conflicted; rerun refresh with --force to override"
.to_owned(),
)
- .with_attempt_id(attempt_id.clone()),
+ .with_attempt_id(attempt_id.clone())
+ .with_planned_repair_relays(blocked_refresh_plan.planned_repair_relays.clone())
+ .with_blocked_relays(
+ "conflicted_relays",
+ relay_summary.conflicted_relays.clone(),
+ ),
);
return Err(
MycError::InvalidOperation(
@@ -716,8 +736,10 @@ pub async fn refresh_nip89(
);
}
- let refresh_relays = select_refresh_relays(&context, &relay_states, force)
+ let refresh_plan = build_refresh_plan(&context, &relay_states, force)
.map_err(|error| error.with_discovery_refresh_attempt_id(attempt_id.clone()))?;
+ let refresh_relays = refresh_plan.selected_relays;
+ let refresh_relay_urls = relay_urls_to_strings(&refresh_relays);
if refresh_relays.is_empty() {
let repair_results = build_repair_results(&context, &relay_states, &[], None, None);
@@ -738,7 +760,8 @@ pub async fn refresh_nip89(
relay_count.saturating_sub(relay_summary.unavailable_relays.len()),
"local discovery handler already matches live state".to_owned(),
)
- .with_attempt_id(attempt_id.clone()),
+ .with_attempt_id(attempt_id.clone())
+ .with_planned_repair_relays(refresh_relay_urls.clone()),
);
return Ok(MycRefreshedNip89Output {
attempt_id,
@@ -796,7 +819,8 @@ pub async fn refresh_nip89(
repair_summary.skipped
),
)
- .with_attempt_id(attempt_id.clone()),
+ .with_attempt_id(attempt_id.clone())
+ .with_planned_repair_relays(refresh_relay_urls.clone()),
);
return Ok(MycRefreshedNip89Output {
attempt_id,
@@ -838,13 +862,30 @@ pub async fn refresh_nip89(
repair_summary.skipped
),
)
- .with_attempt_id(attempt_id.clone()),
+ .with_attempt_id(attempt_id.clone())
+ .with_planned_repair_relays(refresh_relay_urls.clone()),
);
return Err(error.with_discovery_refresh_attempt_id(attempt_id));
}
}
}
+fn build_refresh_plan(
+ context: &MycDiscoveryContext,
+ relay_states: &[MycDiscoveryRelayState],
+ force: bool,
+) -> Result<MycDiscoveryRefreshPlan, MycError> {
+ let selected_relays = select_refresh_relays(context, relay_states, force)?;
+ Ok(MycDiscoveryRefreshPlan {
+ selected_relays: selected_relays.clone(),
+ planned_repair_relays: relay_urls_to_strings(&selected_relays),
+ })
+}
+
+fn relay_urls_to_strings(relays: &[RadrootsNostrRelayUrl]) -> Vec<String> {
+ relays.iter().map(ToString::to_string).collect()
+}
+
fn select_refresh_relays(
context: &MycDiscoveryContext,
relay_states: &[MycDiscoveryRelayState],
diff --git a/src/main.rs b/src/main.rs
@@ -1,5 +1,7 @@
#![forbid(unsafe_code)]
+use serde_json::json;
+
#[tokio::main]
async fn main() {
if let Err(err) = myc::run_cli().await {
@@ -9,6 +11,14 @@ async fn main() {
eprintln!(
"myc: inspect with `myc audit discovery-repair-attempt --attempt-id {attempt_id}`"
);
+ let hint = json!({
+ "attempt_id": attempt_id,
+ "inspect_args": ["audit", "discovery-repair-attempt", "--attempt-id", attempt_id],
+ });
+ eprintln!(
+ "myc: discovery repair attempt json: {}",
+ serde_json::to_string(&hint).expect("discovery repair attempt hint json")
+ );
}
std::process::exit(1);
}
diff --git a/tests/discovery_cli.rs b/tests/discovery_cli.rs
@@ -387,6 +387,13 @@ fn extract_discovery_attempt_id(stderr: &str) -> Option<&str> {
.find_map(|line| line.strip_prefix("myc: discovery repair attempt id: "))
}
+fn extract_discovery_attempt_hint(stderr: &str) -> Option<Value> {
+ stderr.lines().find_map(|line| {
+ line.strip_prefix("myc: discovery repair attempt json: ")
+ .and_then(|json| serde_json::from_str(json).ok())
+ })
+}
+
fn unavailable_relay_url() -> TestResult<String> {
let listener = StdTcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
@@ -694,6 +701,20 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> {
);
let refresh_stderr = String::from_utf8_lossy(&refresh.stderr);
let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id");
+ let attempt_hint = extract_discovery_attempt_hint(&refresh_stderr).expect("attempt hint");
+ assert_eq!(
+ attempt_hint["attempt_id"],
+ Value::String(attempt_id.to_owned())
+ );
+ assert_eq!(
+ attempt_hint["inspect_args"],
+ Value::Array(vec![
+ Value::String("audit".to_owned()),
+ Value::String("discovery-repair-attempt".to_owned()),
+ Value::String("--attempt-id".to_owned()),
+ Value::String(attempt_id.to_owned()),
+ ])
+ );
let attempt = run_myc(
&config_path,
&[
@@ -717,6 +738,22 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> {
attempt_output["refresh_outcome"],
Value::String("conflicted".to_owned())
);
+ assert_eq!(
+ attempt_output["planned_repair_relays"],
+ Value::Array(vec![Value::String(relay.url().to_owned())])
+ );
+ assert_eq!(
+ attempt_output["blocked_relays"],
+ Value::Array(vec![Value::String(relay.url().to_owned())])
+ );
+ assert_eq!(
+ attempt_output["blocked_reason"],
+ Value::String("conflicted_relays".to_owned())
+ );
+ assert_eq!(
+ attempt_output["remaining_repair_relays"],
+ Value::Array(vec![Value::String(relay.url().to_owned())])
+ );
let forced_refresh = run_myc(&config_path, &["discovery", "refresh-nip89", "--force"])?;
assert!(
@@ -880,6 +917,11 @@ async fn failed_refresh_publish_surfaces_attempt_id_and_exact_audit_lookup() ->
"unexpected refresh stderr: {refresh_stderr}"
);
let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id");
+ let attempt_hint = extract_discovery_attempt_hint(&refresh_stderr).expect("attempt hint");
+ assert_eq!(
+ attempt_hint["attempt_id"],
+ Value::String(attempt_id.to_owned())
+ );
let attempt = run_myc(
&config_path,
@@ -916,6 +958,12 @@ async fn failed_refresh_publish_surfaces_attempt_id_and_exact_audit_lookup() ->
attempt_output["remaining_repair_relays"],
Value::Array(vec![Value::String(relay.url().to_owned())])
);
+ assert_eq!(
+ attempt_output["planned_repair_relays"],
+ Value::Array(vec![Value::String(relay.url().to_owned())])
+ );
+ assert_eq!(attempt_output["blocked_relays"], Value::Array(vec![]));
+ assert!(attempt_output["blocked_reason"].is_null());
Ok(())
}
@@ -1309,6 +1357,11 @@ async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_th
);
let refresh_stderr = String::from_utf8_lossy(&refresh.stderr);
let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id");
+ let attempt_hint = extract_discovery_attempt_hint(&refresh_stderr).expect("attempt hint");
+ assert_eq!(
+ attempt_hint["attempt_id"],
+ Value::String(attempt_id.to_owned())
+ );
let attempt = run_myc(
&config_path,
&[
@@ -1332,6 +1385,22 @@ async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_th
attempt_output["refresh_outcome"],
Value::String("unavailable".to_owned())
);
+ assert_eq!(
+ attempt_output["planned_repair_relays"],
+ Value::Array(vec![Value::String(relay.url().to_owned())])
+ );
+ assert_eq!(
+ attempt_output["blocked_relays"],
+ Value::Array(vec![Value::String(unavailable_relay.clone())])
+ );
+ assert_eq!(
+ attempt_output["blocked_reason"],
+ Value::String("unavailable_relays".to_owned())
+ );
+ assert_eq!(
+ attempt_output["remaining_repair_relays"],
+ Value::Array(vec![Value::String(relay.url().to_owned())])
+ );
let forced_refresh = run_myc(&config_path, &["discovery", "refresh-nip89", "--force"])?;
assert!(
@@ -1349,3 +1418,96 @@ async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_th
Ok(())
}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn refresh_surfaces_blocked_summary_when_all_discovery_relays_are_unavailable()
+-> TestResult<()> {
+ let unavailable_relay = unavailable_relay_url()?;
+ 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");
+
+ write_identity(
+ &signer_identity_path,
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ );
+ write_identity(
+ &user_identity_path,
+ "2222222222222222222222222222222222222222222222222222222222222222",
+ );
+ write_identity(
+ &app_identity_path,
+ "3333333333333333333333333333333333333333333333333333333333333333",
+ );
+ write_config(
+ &config_path,
+ &state_dir,
+ &signer_identity_path,
+ &user_identity_path,
+ &app_identity_path,
+ &[unavailable_relay.as_str()],
+ );
+
+ 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("failed to fetch discovery state from all configured relays"),
+ "unexpected refresh stderr: {refresh_stderr}"
+ );
+ let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id");
+ let attempt_hint = extract_discovery_attempt_hint(&refresh_stderr).expect("attempt hint");
+ assert_eq!(
+ attempt_hint["attempt_id"],
+ Value::String(attempt_id.to_owned())
+ );
+
+ 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())
+ );
+ assert_eq!(
+ attempt_output["planned_repair_relays"],
+ Value::Array(vec![])
+ );
+ assert_eq!(
+ attempt_output["blocked_relays"],
+ Value::Array(vec![Value::String(unavailable_relay)])
+ );
+ assert_eq!(
+ attempt_output["blocked_reason"],
+ Value::String("all_relays_unavailable".to_owned())
+ );
+ assert_eq!(
+ attempt_output["remaining_repair_relays"],
+ Value::Array(vec![])
+ );
+
+ Ok(())
+}