| 1 | //! Native-client projection of the existing event, payload and notification |
| 2 | //! policy owners. This endpoint prepares an attempt; it never submits a banner. |
| 3 | |
| 4 | use super::*; |
| 5 | use crate::runtime_threads::{RuntimeEventRecord, RuntimeTurnStatus}; |
| 6 | use crate::tui::notification_payload::NotificationPayload; |
| 7 | use crate::tui::{notification_audio, notifications, sound_policy}; |
| 8 | use codewhale_localization::{Locale, MessageId, tr}; |
| 9 | |
| 10 | #[derive(Deserialize)] |
| 11 | #[serde(deny_unknown_fields)] |
| 12 | pub(super) struct PrepareRequest { |
| 13 | seq: u64, |
| 14 | focused: bool, |
| 15 | unfocused_for_ms: u64, |
| 16 | locale: String, |
| 17 | } |
| 18 | |
| 19 | #[derive(Debug, Serialize)] |
| 20 | pub(super) struct PreparedNotification { |
| 21 | status: &'static str, |
| 22 | #[serde(skip_serializing_if = "Option::is_none")] |
| 23 | headline: Option<String>, |
| 24 | #[serde(skip_serializing_if = "Option::is_none")] |
| 25 | body: Option<String>, |
| 26 | /// A selection, never an audio receipt. No file paths cross this boundary. |
| 27 | sound: &'static str, |
| 28 | } |
| 29 | |
| 30 | impl PreparedNotification { |
| 31 | fn suppressed(status: &'static str) -> Self { |
| 32 | Self { |
| 33 | status, |
| 34 | headline: None, |
| 35 | body: None, |
| 36 | sound: "off", |
| 37 | } |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | pub(super) async fn prepare( |
| 42 | State(state): State<RuntimeApiState>, |
| 43 | Path(id): Path<String>, |
| 44 | Json(request): Json<PrepareRequest>, |
| 45 | ) -> Result<Json<PreparedNotification>, ApiError> { |
| 46 | let Some(previous) = request.seq.checked_sub(1) else { |
| 47 | return Err(ApiError::bad_request( |
| 48 | "notification sequence must be positive", |
| 49 | )); |
| 50 | }; |
| 51 | // Read the selected thread's durable event, never a renderer-supplied copy |
| 52 | // or title. Dropping the replay receiver stops the bounded reader. |
| 53 | let mut replay = state |
| 54 | .runtime_threads |
| 55 | .replay_events(&id, Some(previous), None) |
| 56 | .await |
| 57 | .map_err(|error| ApiError::internal(error.to_string()))?; |
| 58 | let mut selected = None; |
| 59 | while let Some(batch) = replay.batches.recv().await { |
| 60 | let batch = batch.map_err(ApiError::internal)?; |
| 61 | if let Some(event) = batch.into_iter().next() { |
| 62 | if event.seq == request.seq { |
| 63 | selected = Some(event); |
| 64 | } |
| 65 | break; |
| 66 | } |
| 67 | } |
| 68 | let event = selected.ok_or_else(|| { |
| 69 | ApiError::bad_request("notification event does not belong to this thread") |
| 70 | })?; |
| 71 | let locale = Locale::shipped() |
| 72 | .iter() |
| 73 | .copied() |
| 74 | .find(|locale| locale.tag().eq_ignore_ascii_case(&request.locale)) |
| 75 | .or_else(|| matches!(request.locale.as_str(), "zh" | "zh-CN").then_some(Locale::ZhHans)) |
| 76 | .ok_or_else(|| ApiError::bad_request("unsupported notification locale"))?; |
| 77 | // Replay can wait on disk while the same turn's request settles. Validate |
| 78 | // the selected record against a fresh snapshot, with no later await. |
| 79 | let detail = state |
| 80 | .runtime_threads |
| 81 | .get_thread_detail(&id) |
| 82 | .await |
| 83 | .map_err(map_thread_err)?; |
| 84 | let config = state.config.read().clone(); |
| 85 | Ok(Json(prepare_record( |
| 86 | &config, |
| 87 | &detail, |
| 88 | &event, |
| 89 | request.focused, |
| 90 | Duration::from_millis(request.unfocused_for_ms), |
| 91 | locale, |
| 92 | Utc::now(), |
| 93 | ))) |
| 94 | } |
| 95 | |
| 96 | #[allow(clippy::too_many_arguments)] // Canonical snapshot plus observed host facts; no second policy object. |
| 97 | fn prepare_record( |
| 98 | config: &Config, |
| 99 | detail: &ThreadDetail, |
| 100 | event: &RuntimeEventRecord, |
| 101 | focused: bool, |
| 102 | unfocused_for: Duration, |
| 103 | locale: Locale, |
| 104 | now: chrono::DateTime<Utc>, |
| 105 | ) -> PreparedNotification { |
| 106 | // Old/recovered records remain visible in the work history but cannot |
| 107 | // become a fresh OS interruption. The host also anchors its replay cursor. |
| 108 | let age = now.signed_duration_since(event.timestamp).num_seconds(); |
| 109 | if !(0..=60).contains(&age) || event.payload.get("recovered") == Some(&json!(true)) { |
| 110 | return PreparedNotification::suppressed("expired"); |
| 111 | } |
| 112 | let Some(turn) = detail |
| 113 | .turns |
| 114 | .iter() |
| 115 | .find(|turn| Some(&turn.id) == event.turn_id.as_ref()) |
| 116 | else { |
| 117 | return PreparedNotification::suppressed("settled"); |
| 118 | }; |
| 119 | if detail.thread.latest_turn_id.as_deref() != Some(turn.id.as_str()) { |
| 120 | return PreparedNotification::suppressed("settled"); |
| 121 | } |
| 122 | if matches!( |
| 123 | event.event.as_str(), |
| 124 | "approval.required" | "user_input.required" |
| 125 | ) && !matches!( |
| 126 | turn.status, |
| 127 | RuntimeTurnStatus::Queued | RuntimeTurnStatus::InProgress |
| 128 | ) { |
| 129 | return PreparedNotification::suppressed("settled"); |
| 130 | } |
| 131 | let payload = match event.event.as_str() { |
| 132 | "turn.completed" if turn.status == RuntimeTurnStatus::Completed => { |
| 133 | NotificationPayload::turn_complete(&tr(locale, MessageId::NotificationTurnComplete)) |
| 134 | } |
| 135 | "approval.required" => { |
| 136 | let Some(pending) = detail.pending_approvals.iter().find(|pending| { |
| 137 | event.payload.get("id").and_then(Value::as_str) == Some(pending.id.as_str()) |
| 138 | && pending.turn_id == turn.id |
| 139 | }) else { |
| 140 | return PreparedNotification::suppressed("settled"); |
| 141 | }; |
| 142 | NotificationPayload::approval_needed( |
| 143 | &tr(locale, MessageId::ConfigLabelNotificationApprovalNeeded), |
| 144 | &pending.tool_name, |
| 145 | ) |
| 146 | } |
| 147 | "user_input.required" |
| 148 | if detail.pending_user_inputs.iter().any(|pending| { |
| 149 | event.payload.get("id").and_then(Value::as_str) == Some(pending.id.as_str()) |
| 150 | && pending.turn_id == turn.id |
| 151 | }) => |
| 152 | { |
| 153 | NotificationPayload::input_needed(&tr( |
| 154 | locale, |
| 155 | MessageId::ConfigLabelNotificationInputNeeded, |
| 156 | )) |
| 157 | } |
| 158 | _ => return PreparedNotification::suppressed("unsupported_event"), |
| 159 | }; |
| 160 | prepare_payload( |
| 161 | config, |
| 162 | &payload, |
| 163 | Duration::from_millis(turn.duration_ms.unwrap_or(0)), |
| 164 | focused, |
| 165 | unfocused_for, |
| 166 | ) |
| 167 | } |
| 168 | |
| 169 | fn prepare_payload( |
| 170 | config: &Config, |
| 171 | payload: &NotificationPayload, |
| 172 | elapsed: Duration, |
| 173 | focused: bool, |
| 174 | unfocused_for: Duration, |
| 175 | ) -> PreparedNotification { |
| 176 | let notification_config = config.notifications_config(); |
| 177 | let Some((method, threshold, _)) = notifications::settings_projection(config) else { |
| 178 | return PreparedNotification::suppressed("suppressed"); |
| 179 | }; |
| 180 | let method = match method { |
| 181 | notifications::Method::Auto => notifications::Method::MacOS, |
| 182 | notifications::Method::Off => notifications::Method::Off, |
| 183 | _ => return PreparedNotification::suppressed("unsupported_method"), |
| 184 | }; |
| 185 | let attention = |
| 186 | notifications::native_attention_allowed(¬ification_config, focused, unfocused_for); |
| 187 | let threshold = |
| 188 | if payload.kind() == crate::tui::notification_payload::NotificationKind::TurnComplete { |
| 189 | threshold |
| 190 | } else { |
| 191 | Duration::ZERO |
| 192 | }; |
| 193 | let mut sound = "off"; |
| 194 | // Capture the shared policy's intended sinks. Neither closure performs IO; |
| 195 | // the response says prepared, never dispatched/delivered. The native host |
| 196 | // owns the subsequent permission check and submission receipt. |
| 197 | let outcome = notifications::notify_with_sinks( |
| 198 | method, |
| 199 | false, |
| 200 | payload, |
| 201 | threshold, |
| 202 | elapsed, |
| 203 | notifications::NotificationGate::from_config(¬ification_config), |
| 204 | attention, |
| 205 | &mut std::io::sink(), |
| 206 | &mut |kind, bell| { |
| 207 | sound_policy::decide_configured( |
| 208 | ¬ification_config, |
| 209 | kind, |
| 210 | sound_policy::epoch_millis_now(), |
| 211 | bell, |
| 212 | ) |
| 213 | }, |
| 214 | &mut |cue, _| { |
| 215 | sound = match cue { |
| 216 | sound_policy::SoundCue::Beep => "beep", |
| 217 | sound_policy::SoundCue::Whale => "whale", |
| 218 | sound_policy::SoundCue::File(_) => "file", |
| 219 | sound_policy::SoundCue::Bell => "bell", |
| 220 | sound_policy::SoundCue::DoubleBell => "double-bell", |
| 221 | }; |
| 222 | notification_audio::AudioOutcome::Dispatched |
| 223 | }, |
| 224 | &mut |_| notifications::DeliveryOutcome::Dispatched(notifications::Method::MacOS), |
| 225 | ); |
| 226 | if !matches!(outcome, notifications::DeliveryOutcome::Dispatched(_)) { |
| 227 | return PreparedNotification::suppressed("suppressed"); |
| 228 | } |
| 229 | PreparedNotification { |
| 230 | status: "prepared", |
| 231 | headline: Some(payload.headline().to_string()), |
| 232 | body: Some(payload.body()), |
| 233 | sound, |
| 234 | } |
| 235 | } |
| 236 |