commit 82849b0b980ef26adfd3a42a35d22de8430a71b7
parent f69cf665dc584644e0bbf9b1506e2828b89f1193
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 16:10:33 +0000
cli: separate repair and publish summary signals
- add a compact repair_summary to refresh output while keeping remaining_repair_relays explicit for follow-up repair
- split audit summary counts between aggregate publish rejections and per-relay discovery repair outcomes
- add unit and cli smoke coverage for mixed-success targeted repair accounting
- validate with cargo metadata --format-version 1 --no-deps, cargo fmt --all --check, cargo check --locked, and cargo test --locked
Diffstat:
5 files changed, 324 insertions(+), 25 deletions(-)
diff --git a/src/cli.rs b/src/cli.rs
@@ -188,7 +188,9 @@ pub struct MycAuditSummaryOutput {
pub runtime_operation_total: usize,
pub runtime_operation_outcomes: MycOperationOutcomeCounts,
pub runtime_operation_by_kind: BTreeMap<String, MycOperationOutcomeCounts>,
- pub runtime_publish_rejection_count: usize,
+ pub runtime_aggregate_publish_rejection_count: usize,
+ pub runtime_repair_success_count: usize,
+ pub runtime_repair_rejection_count: usize,
pub runtime_unavailable_count: usize,
pub runtime_replay_restore_count: usize,
}
@@ -455,7 +457,9 @@ fn summarize_audit_output(
let mut runtime_operation_outcomes = MycOperationOutcomeCounts::default();
let mut runtime_operation_by_kind = BTreeMap::new();
- let mut runtime_publish_rejection_count = 0;
+ let mut runtime_aggregate_publish_rejection_count = 0;
+ let mut runtime_repair_success_count = 0;
+ let mut runtime_repair_rejection_count = 0;
let mut runtime_unavailable_count = 0;
let mut runtime_replay_restore_count = 0;
for record in &audit.runtime_operation_audit {
@@ -465,8 +469,17 @@ fn summarize_audit_output(
runtime_operation_by_kind.entry(key).or_default(),
record.outcome,
);
- if record.outcome == MycOperationAuditOutcome::Rejected {
- runtime_publish_rejection_count += 1;
+ if is_aggregate_publish_operation(record.operation)
+ && record.outcome == MycOperationAuditOutcome::Rejected
+ {
+ runtime_aggregate_publish_rejection_count += 1;
+ }
+ if record.operation == MycOperationAuditKind::DiscoveryHandlerRepair {
+ match record.outcome {
+ MycOperationAuditOutcome::Succeeded => runtime_repair_success_count += 1,
+ MycOperationAuditOutcome::Rejected => runtime_repair_rejection_count += 1,
+ _ => {}
+ }
}
if record.outcome == MycOperationAuditOutcome::Unavailable {
runtime_unavailable_count += 1;
@@ -485,7 +498,9 @@ fn summarize_audit_output(
runtime_operation_total: audit.runtime_operation_audit.len(),
runtime_operation_outcomes,
runtime_operation_by_kind,
- runtime_publish_rejection_count,
+ runtime_aggregate_publish_rejection_count,
+ runtime_repair_success_count,
+ runtime_repair_rejection_count,
runtime_unavailable_count,
runtime_replay_restore_count,
})
@@ -526,6 +541,16 @@ fn operation_kind_label(kind: MycOperationAuditKind) -> String {
}
}
+fn is_aggregate_publish_operation(kind: MycOperationAuditKind) -> bool {
+ matches!(
+ kind,
+ MycOperationAuditKind::ListenerResponsePublish
+ | MycOperationAuditKind::ConnectAcceptPublish
+ | MycOperationAuditKind::AuthReplayPublish
+ | MycOperationAuditKind::DiscoveryHandlerPublish
+ )
+}
+
fn print_json<T>(value: &T) -> Result<(), MycError>
where
T: Serialize,
@@ -707,7 +732,9 @@ mod tests {
assert_eq!(summary.runtime_operation_total, 2);
assert_eq!(summary.runtime_operation_outcomes.succeeded, 1);
assert_eq!(summary.runtime_operation_outcomes.restored, 1);
- assert_eq!(summary.runtime_publish_rejection_count, 0);
+ assert_eq!(summary.runtime_aggregate_publish_rejection_count, 0);
+ assert_eq!(summary.runtime_repair_success_count, 0);
+ assert_eq!(summary.runtime_repair_rejection_count, 0);
assert_eq!(summary.runtime_unavailable_count, 0);
assert_eq!(summary.runtime_replay_restore_count, 1);
assert_eq!(
@@ -729,4 +756,98 @@ mod tests {
assert_eq!(denied.audit.request_id.as_str(), "request-1");
assert_eq!(challenged_eval.audit.request_id.as_str(), "request-2");
}
+
+ #[test]
+ fn audit_summary_separates_repair_rejections_from_aggregate_publish_rejections() {
+ let runtime = runtime();
+ let manager = runtime.signer_manager().expect("manager");
+ let connection = manager
+ .register_connection(RadrootsNostrSignerConnectionDraft::new(
+ nostr::Keys::generate().public_key(),
+ runtime.user_public_identity(),
+ ))
+ .expect("register connection");
+
+ runtime.record_operation_audit(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::DiscoveryHandlerPublish,
+ MycOperationAuditOutcome::Succeeded,
+ Some(&connection.connection_id),
+ Some("request-1"),
+ 2,
+ 1,
+ "1/2 relays acknowledged publish; failures: relay-b: blocked",
+ ));
+ runtime.record_operation_audit(
+ &MycOperationAuditRecord::new(
+ MycOperationAuditKind::DiscoveryHandlerRepair,
+ MycOperationAuditOutcome::Succeeded,
+ Some(&connection.connection_id),
+ Some("request-1"),
+ 1,
+ 1,
+ "relay repaired",
+ )
+ .with_relay_url("wss://relay-a.example.com"),
+ );
+ runtime.record_operation_audit(
+ &MycOperationAuditRecord::new(
+ MycOperationAuditKind::DiscoveryHandlerRepair,
+ MycOperationAuditOutcome::Rejected,
+ Some(&connection.connection_id),
+ Some("request-1"),
+ 1,
+ 0,
+ "blocked by relay",
+ )
+ .with_relay_url("wss://relay-b.example.com"),
+ );
+ runtime.record_operation_audit(&MycOperationAuditRecord::new(
+ MycOperationAuditKind::ListenerResponsePublish,
+ MycOperationAuditOutcome::Rejected,
+ Some(&connection.connection_id),
+ Some("request-2"),
+ 1,
+ 0,
+ "listener publish rejected",
+ ));
+
+ let summary = summarize_audit_output(
+ &runtime,
+ &manager,
+ Some(connection.connection_id.as_str()),
+ MycAuditScope::Operation,
+ Some(10),
+ )
+ .expect("summary");
+
+ assert_eq!(summary.runtime_operation_total, 4);
+ assert_eq!(summary.runtime_aggregate_publish_rejection_count, 1);
+ assert_eq!(summary.runtime_repair_success_count, 1);
+ assert_eq!(summary.runtime_repair_rejection_count, 1);
+ assert_eq!(summary.runtime_replay_restore_count, 0);
+ assert_eq!(
+ summary
+ .runtime_operation_by_kind
+ .get("discovery_handler_publish")
+ .expect("publish kind")
+ .succeeded,
+ 1
+ );
+ assert_eq!(
+ summary
+ .runtime_operation_by_kind
+ .get("discovery_handler_repair")
+ .expect("repair kind")
+ .succeeded,
+ 1
+ );
+ assert_eq!(
+ summary
+ .runtime_operation_by_kind
+ .get("discovery_handler_repair")
+ .expect("repair kind")
+ .rejected,
+ 1
+ );
+ }
}
diff --git a/src/discovery.rs b/src/discovery.rs
@@ -91,6 +91,14 @@ pub enum MycDiscoveryRepairOutcome {
Skipped,
}
+#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
+pub struct MycDiscoveryRepairSummary {
+ pub repaired: usize,
+ pub failed: usize,
+ pub unchanged: usize,
+ pub skipped: usize,
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MycDiscoveryRelayRepairResult {
pub relay_url: String,
@@ -200,6 +208,7 @@ pub struct MycRefreshedNip89Output {
pub live_groups: Vec<MycLiveNip89Group>,
pub relay_states: Vec<MycDiscoveryRelayState>,
pub relay_summary: MycDiscoveryRelaySummary,
+ pub repair_summary: MycDiscoveryRepairSummary,
pub repair_results: Vec<MycDiscoveryRelayRepairResult>,
pub remaining_repair_relays: Vec<String>,
pub published: Option<MycPublishedNip89Output>,
@@ -651,6 +660,7 @@ pub async fn refresh_nip89(
if refresh_relays.is_empty() {
let repair_results = build_repair_results(&context, &relay_states, &[], None, None);
+ let repair_summary = summarize_repair_results(&repair_results);
runtime.record_operation_audit(&MycOperationAuditRecord::new(
MycOperationAuditKind::DiscoveryHandlerRefresh,
MycOperationAuditOutcome::Skipped,
@@ -667,6 +677,7 @@ pub async fn refresh_nip89(
live_groups,
relay_states,
relay_summary,
+ repair_summary,
repair_results,
remaining_repair_relays: Vec::new(),
published: None,
@@ -687,6 +698,7 @@ pub async fn refresh_nip89(
Some(published.event.id.to_hex()),
&repair_results,
);
+ let repair_summary = summarize_repair_results(&repair_results);
let remaining_repair_relays = remaining_repair_relays(&repair_results);
return Ok(MycRefreshedNip89Output {
status,
@@ -695,6 +707,7 @@ pub async fn refresh_nip89(
live_groups,
relay_states,
relay_summary,
+ repair_summary,
repair_results,
remaining_repair_relays,
published: Some(published),
@@ -845,6 +858,21 @@ fn remaining_repair_relays(repair_results: &[MycDiscoveryRelayRepairResult]) ->
.collect()
}
+fn summarize_repair_results(
+ repair_results: &[MycDiscoveryRelayRepairResult],
+) -> MycDiscoveryRepairSummary {
+ let mut summary = MycDiscoveryRepairSummary::default();
+ for result in repair_results {
+ match result.outcome {
+ MycDiscoveryRepairOutcome::Repaired => summary.repaired += 1,
+ MycDiscoveryRepairOutcome::Failed => summary.failed += 1,
+ MycDiscoveryRepairOutcome::Unchanged => summary.unchanged += 1,
+ MycDiscoveryRepairOutcome::Skipped => summary.skipped += 1,
+ }
+ }
+ summary
+}
+
fn record_refresh_repair_audit(
runtime: &MycRuntime,
request_id: Option<String>,
diff --git a/src/lib.rs b/src/lib.rs
@@ -25,11 +25,12 @@ pub use discovery::{
MycDiscoveryBundleManifest, MycDiscoveryBundleOutput, MycDiscoveryContext,
MycDiscoveryDiffOutput, MycDiscoveryLiveStatus, MycDiscoveryRelayFetchStatus,
MycDiscoveryRelayRepairResult, MycDiscoveryRelayState, MycDiscoveryRelaySummary,
- MycDiscoveryRepairOutcome, MycFetchedLiveNip89Output, MycLiveNip89Event, MycLiveNip89Group,
- MycLiveNip89RelayState, MycNip05Document, MycNip05DocumentSection, MycNip89HandlerDocument,
- MycNormalizedNip89Handler, MycPublishedNip89Output, MycRefreshedNip89Output,
- MycRenderedNip05Output, MycRenderedNip89Output, diff_live_nip89, fetch_live_nip89,
- publish_nip89_event, refresh_nip89, render_nip05_output, verify_bundle,
+ MycDiscoveryRepairOutcome, MycDiscoveryRepairSummary, MycFetchedLiveNip89Output,
+ MycLiveNip89Event, MycLiveNip89Group, MycLiveNip89RelayState, MycNip05Document,
+ MycNip05DocumentSection, MycNip89HandlerDocument, MycNormalizedNip89Handler,
+ MycPublishedNip89Output, MycRefreshedNip89Output, MycRenderedNip05Output,
+ MycRenderedNip89Output, diff_live_nip89, fetch_live_nip89, publish_nip89_event, refresh_nip89,
+ render_nip05_output, verify_bundle,
};
pub use error::MycError;
pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot};
diff --git a/tests/discovery_cli.rs b/tests/discovery_cli.rs
@@ -1,4 +1,4 @@
-use std::collections::HashMap;
+use std::collections::{HashMap, VecDeque};
use std::fs;
use std::net::TcpListener as StdTcpListener;
use std::path::Path;
@@ -36,6 +36,7 @@ struct RelayState {
senders: HashMap<usize, mpsc::UnboundedSender<Message>>,
subscriptions: Vec<RelaySubscription>,
published_events: Vec<Event>,
+ publish_outcomes_by_pubkey: HashMap<String, VecDeque<bool>>,
}
struct TestRelay {
@@ -86,6 +87,13 @@ impl TestRelay {
self.url.as_str()
}
+ async fn queue_publish_outcomes(&self, public_key: PublicKey, outcomes: &[bool]) {
+ let mut state = self.state.lock().await;
+ state
+ .publish_outcomes_by_pubkey
+ .insert(public_key.to_hex(), outcomes.iter().copied().collect());
+ }
+
async fn wait_for_published_events_by_author(
&self,
public_key: PublicKey,
@@ -247,28 +255,43 @@ async fn accept_published_event(
Vec<(mpsc::UnboundedSender<Message>, Message)>,
)> {
let event_id = event.id;
+ let event_pubkey_hex = event.pubkey.to_hex();
let mut subscriber_messages = Vec::new();
let mut ok_message = None;
{
let mut state = state.lock().await;
+ let publish_status = state
+ .publish_outcomes_by_pubkey
+ .get_mut(&event_pubkey_hex)
+ .and_then(|outcomes| outcomes.pop_front())
+ .unwrap_or(true);
+
if let Some(sender) = state.senders.get(&connection_id).cloned() {
- let message = RelayMessage::ok(event_id, true, "").as_json();
+ let message = if publish_status {
+ RelayMessage::ok(event_id, true, "").as_json()
+ } else {
+ RelayMessage::ok(event_id, false, "blocked by test relay").as_json()
+ };
ok_message = Some((sender, Message::Text(message.into())));
}
- state.published_events.push(event.clone());
- for subscription in &state.subscriptions {
- if subscription
- .filters
- .iter()
- .any(|filter| filter.match_event(&event, MatchEventOptions::new()))
- {
- if let Some(sender) = state.senders.get(&subscription.connection_id).cloned() {
- let message =
- RelayMessage::event(subscription.subscription_id.clone(), event.clone())
- .as_json();
- subscriber_messages.push((sender, Message::Text(message.into())));
+ if publish_status {
+ state.published_events.push(event.clone());
+ for subscription in &state.subscriptions {
+ if subscription
+ .filters
+ .iter()
+ .any(|filter| filter.match_event(&event, MatchEventOptions::new()))
+ {
+ if let Some(sender) = state.senders.get(&subscription.connection_id).cloned() {
+ let message = RelayMessage::event(
+ subscription.subscription_id.clone(),
+ event.clone(),
+ )
+ .as_json();
+ subscriber_messages.push((sender, Message::Text(message.into())));
+ }
}
}
}
@@ -678,6 +701,108 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn refresh_reports_partial_repair_and_audit_summary_through_the_cli() -> TestResult<()> {
+ let relay_a = TestRelay::spawn().await?;
+ let relay_b = 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_a.url(), relay_b.url()],
+ );
+
+ relay_a
+ .queue_publish_outcomes(app_identity.public_key(), &[true])
+ .await;
+ relay_b
+ .queue_publish_outcomes(app_identity.public_key(), &[false])
+ .await;
+
+ let refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?;
+ assert!(
+ refresh.status.success(),
+ "refresh-nip89 failed: {}",
+ String::from_utf8_lossy(&refresh.stderr)
+ );
+ let refresh_output: Value = serde_json::from_slice(&refresh.stdout)?;
+ assert_eq!(refresh_output["status"], "missing");
+ assert_eq!(refresh_output["repair_summary"]["repaired"], 1);
+ assert_eq!(refresh_output["repair_summary"]["failed"], 1);
+ assert_eq!(refresh_output["repair_summary"]["unchanged"], 0);
+ assert_eq!(refresh_output["repair_summary"]["skipped"], 0);
+ assert_eq!(
+ refresh_output["remaining_repair_relays"],
+ Value::Array(vec![Value::String(relay_b.url().to_owned())])
+ );
+ assert_eq!(
+ refresh_output["published"]["acknowledged_relay_count"],
+ Value::from(1_u64)
+ );
+
+ relay_a
+ .wait_for_published_events_by_author(app_identity.public_key(), 1)
+ .await?;
+ assert_eq!(
+ relay_b
+ .published_events_by_author(app_identity.public_key())
+ .await
+ .len(),
+ 0
+ );
+
+ let audit_summary = run_myc(&config_path, &["audit", "summary", "--scope", "operation"])?;
+ assert!(
+ audit_summary.status.success(),
+ "audit summary failed: {}",
+ String::from_utf8_lossy(&audit_summary.stderr)
+ );
+ let audit_summary_output: Value = serde_json::from_slice(&audit_summary.stdout)?;
+ assert_eq!(
+ audit_summary_output["runtime_aggregate_publish_rejection_count"],
+ Value::from(0_u64)
+ );
+ assert_eq!(
+ audit_summary_output["runtime_repair_success_count"],
+ Value::from(1_u64)
+ );
+ assert_eq!(
+ audit_summary_output["runtime_repair_rejection_count"],
+ Value::from(1_u64)
+ );
+ assert_eq!(
+ audit_summary_output["runtime_operation_by_kind"]["discovery_handler_publish"]["succeeded"],
+ Value::from(1_u64)
+ );
+ assert_eq!(
+ audit_summary_output["runtime_operation_by_kind"]["discovery_handler_repair"]["rejected"],
+ Value::from(1_u64)
+ );
+
+ Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn discovery_diff_surfaces_relay_provenance_through_the_cli() -> TestResult<()> {
let relay_a = TestRelay::spawn().await?;
let relay_b = TestRelay::spawn().await?;
diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs
@@ -1391,6 +1391,10 @@ async fn refresh_nip89_publishes_when_live_handler_is_missing() -> TestResult<()
assert_eq!(refreshed.differing_fields, vec!["live_groups".to_owned()]);
assert!(refreshed.live_groups.is_empty());
assert!(refreshed.published.is_some());
+ assert_eq!(refreshed.repair_summary.repaired, 1);
+ assert_eq!(refreshed.repair_summary.failed, 0);
+ assert_eq!(refreshed.repair_summary.unchanged, 0);
+ assert_eq!(refreshed.repair_summary.skipped, 0);
assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new());
assert_eq!(refreshed.repair_results.len(), 1);
assert_eq!(
@@ -1455,6 +1459,10 @@ async fn refresh_nip89_repairs_missing_relays_without_republishing_matched_relay
assert_eq!(published.publish_relays, vec![relay_b.url().to_owned()]);
assert_eq!(published.relay_count, 1);
assert_eq!(published.acknowledged_relay_count, 1);
+ assert_eq!(refreshed.repair_summary.repaired, 1);
+ assert_eq!(refreshed.repair_summary.failed, 0);
+ assert_eq!(refreshed.repair_summary.unchanged, 1);
+ assert_eq!(refreshed.repair_summary.skipped, 0);
assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new());
assert_eq!(refreshed.repair_results.len(), 2);
assert_eq!(
@@ -1531,6 +1539,10 @@ async fn refresh_nip89_skips_when_live_handler_matches() -> TestResult<()> {
assert!(refreshed.differing_fields.is_empty());
assert_eq!(refreshed.live_groups.len(), 1);
assert!(refreshed.published.is_none());
+ assert_eq!(refreshed.repair_summary.repaired, 0);
+ assert_eq!(refreshed.repair_summary.failed, 0);
+ assert_eq!(refreshed.repair_summary.unchanged, 1);
+ assert_eq!(refreshed.repair_summary.skipped, 0);
assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new());
assert_eq!(refreshed.repair_results.len(), 1);
assert_eq!(
@@ -1589,6 +1601,10 @@ async fn refresh_nip89_republishes_when_live_handler_drifted() -> TestResult<()>
assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Drifted);
assert_eq!(refreshed.live_groups.len(), 1);
assert!(refreshed.published.is_some());
+ assert_eq!(refreshed.repair_summary.repaired, 1);
+ assert_eq!(refreshed.repair_summary.failed, 0);
+ assert_eq!(refreshed.repair_summary.unchanged, 0);
+ assert_eq!(refreshed.repair_summary.skipped, 0);
assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new());
assert_eq!(refreshed.repair_results.len(), 1);
assert_eq!(
@@ -1668,6 +1684,10 @@ async fn refresh_nip89_repairs_drifted_relays_without_force_when_other_relays_ma
assert_eq!(published.publish_relays, vec![relay_b.url().to_owned()]);
assert_eq!(published.relay_count, 1);
assert_eq!(published.acknowledged_relay_count, 1);
+ assert_eq!(refreshed.repair_summary.repaired, 1);
+ assert_eq!(refreshed.repair_summary.failed, 0);
+ assert_eq!(refreshed.repair_summary.unchanged, 1);
+ assert_eq!(refreshed.repair_summary.skipped, 0);
assert_eq!(refreshed.remaining_repair_relays, Vec::<String>::new());
assert_eq!(refreshed.repair_results.len(), 2);
assert_eq!(
@@ -1746,6 +1766,10 @@ async fn refresh_nip89_reports_remaining_relays_after_mixed_targeted_repair() ->
assert_eq!(published.relay_count, 2);
assert_eq!(published.acknowledged_relay_count, 1);
assert_eq!(published.relay_results.len(), 2);
+ assert_eq!(refreshed.repair_summary.repaired, 1);
+ assert_eq!(refreshed.repair_summary.failed, 1);
+ assert_eq!(refreshed.repair_summary.unchanged, 0);
+ assert_eq!(refreshed.repair_summary.skipped, 0);
assert_eq!(refreshed.repair_results.len(), 2);
assert_eq!(
refreshed.remaining_repair_relays,