返回 CodeWhale
notification_delivery.rs
根目录 / crates / tui / src / runtime_api / notification_delivery.rs
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(&notification_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(&notification_config),
204 attention,
205 &mut std::io::sink(),
206 &mut |kind, bell| {
207 sound_policy::decide_configured(
208 &notification_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
236 lines RUST