返回 CodeWhale
remote_control_bridge.rs
根目录 / crates / tui / src / tui / ui / remote_control_bridge.rs
1 //! Remote-control bridge: `/rc` event draining, local-turn attachment, and
2 //! session start projection, extracted from the composition root
3 //! (TUI_MODULARIZATION.md slice 3). The controller in `crate::remote_control`
4 //! owns connection state; this module only projects its events into the UI.
5
6 use super::*;
7
8 pub(crate) async fn drain_remote_control_events(
9 app: &mut App,
10 config: &Config,
11 engine_handle: &EngineHandle,
12 ) -> Result<bool> {
13 // A connection can become ready while a local approval card still owns
14 // the decision. Keep that card local; once it closes, bind the same
15 // already-running typed turn on the next loop tick. If the turn ended in
16 // the meantime this remains an ordinary idle attachment.
17 let mut changed = try_attach_active_local_turn_to_remote(app);
18 while let Some(event) = app.remote_control.try_next_event() {
19 changed = true;
20 match event {
21 crate::remote_control::RemoteEvent::Notice(message) => {
22 app.add_message(HistoryCell::System {
23 content: message.clone(),
24 });
25 app.status_message = Some(message.clone());
26 app.sticky_status =
27 Some(StatusToast::new(message, StatusToastLevel::Warning, None));
28 }
29 crate::remote_control::RemoteEvent::Connected {
30 account_ref,
31 runner_id,
32 attachment,
33 links,
34 ..
35 } => {
36 app.remote_control
37 .upload_snapshot(&attachment.run_id, &app.api_messages);
38 let active_local_turn = local_turn_is_active(app);
39 let attached_active_turn = try_attach_active_local_turn_to_remote(app);
40 let status = crate::remote_control::remote_control_banner(
41 &account_ref,
42 &runner_id,
43 links.run_url.as_deref(),
44 );
45 let mirror_note = if active_local_turn && !attached_active_turn {
46 "A local approval card still owns the current decision; the web joins this turn once it closes."
47 } else {
48 "Web mirror connected. Both surfaces can prompt and decide; one turn runs at a time."
49 };
50 app.add_message(HistoryCell::System {
51 content: format!("{status}\n\n{mirror_note}"),
52 });
53 if let Some(run_url) = links.run_url.as_deref() {
54 app.add_message(HistoryCell::System {
55 content: crate::remote_control::remote_control_link_notice(run_url),
56 });
57 }
58 app.status_message = Some(status.clone());
59 app.sticky_status = Some(StatusToast::new(status, StatusToastLevel::Warning, None));
60 }
61 crate::remote_control::RemoteEvent::Attachment { attachment, .. } => {
62 // Reconnect responses carry the server's current cursor and
63 // snapshot receipt. `try_next_event` applies that truth before
64 // this handler, so this is either a no-op or one bounded retry.
65 app.remote_control
66 .upload_snapshot(&attachment.run_id, &app.api_messages);
67 }
68 crate::remote_control::RemoteEvent::RuntimeCursor { .. } => {
69 // The controller has already retired the acknowledged prefix.
70 }
71 crate::remote_control::RemoteEvent::RuntimeChatHostReleased => {
72 // Internal provider-config handoff; the controller has already
73 // reopened the isolated host and queued its fresh catalog.
74 }
75 crate::remote_control::RemoteEvent::RuntimeChatProjection(_) => {
76 // The controller journaled this isolated native event before
77 // exposing it to the UI bridge. It is web-Chat output, not a
78 // mutation of the current interactive TUI transcript.
79 }
80 crate::remote_control::RemoteEvent::FailedPreLease(error) => {
81 let status = format!("WEB MIRROR · could not start · {error} · /rc to retry");
82 app.status_message = Some(status.clone());
83 app.sticky_status = Some(StatusToast::new(status, StatusToastLevel::Error, None));
84 }
85 crate::remote_control::RemoteEvent::Failed(error) => {
86 let status = format!(
87 "WEB MIRROR LOST · {error} · this terminal is unaffected; reconnecting waits briefly for the server lease to drain"
88 );
89 app.status_message = Some(status.clone());
90 app.sticky_status = Some(StatusToast::new(status, StatusToastLevel::Error, None));
91 }
92 crate::remote_control::RemoteEvent::Stopped => {
93 app.sticky_status = None;
94 app.status_message = Some("Web mirror stopped.".to_string());
95 }
96 crate::remote_control::RemoteEvent::OwnershipRestored { approvals } => {
97 app.sticky_status = None;
98 app.status_message = Some(
99 "The web mirror lease expired; pending approvals stay actionable here."
100 .to_string(),
101 );
102 // Mirror semantics: approval cards were never hidden from
103 // this terminal, so there is nothing to re-show. The drained
104 // list only tells us the web can no longer answer them.
105 let _ = approvals;
106 }
107 crate::remote_control::RemoteEvent::Command {
108 run_id,
109 seq,
110 command,
111 } => {
112 match app.remote_control.claim_command(&run_id, seq, &command) {
113 Ok(true) => {}
114 Ok(false) => {
115 // The native Runtime operation key makes Chat replay
116 // safe. Re-enter it and reissue the terminal command
117 // acknowledgement: the previous provider submission
118 // may be durable even though its acknowledgement POST
119 // was lost with a worker failure.
120 if let crate::remote_control::RemoteCommand::RuntimeChatPrompt(prompt) =
121 &command
122 {
123 match app.remote_control.apply_runtime_chat_prompt(prompt).await {
124 Ok(()) => app
125 .remote_control
126 .acknowledge(&run_id, seq, &command, "applied", None),
127 Err(error) => app.remote_control.acknowledge(
128 &run_id,
129 seq,
130 &command,
131 "failed",
132 Some(error),
133 ),
134 }
135 }
136 continue;
137 }
138 Err(error) => {
139 app.remote_control.acknowledge(
140 &run_id,
141 seq,
142 &command,
143 "failed",
144 Some(error.clone()),
145 );
146 app.remote_control.stop();
147 app.sticky_status = None;
148 app.status_message = Some(error);
149 continue;
150 }
151 }
152 if matches!(
153 &command,
154 crate::remote_control::RemoteCommand::RuntimeChatPrompt(_)
155 ) && local_turn_is_active(app)
156 {
157 app.remote_control.acknowledge(
158 &run_id,
159 seq,
160 &command,
161 "failed",
162 Some(
163 "Finish or interrupt the active local turn before starting Runtime Chat."
164 .to_string(),
165 ),
166 );
167 continue;
168 }
169 match command.clone() {
170 crate::remote_control::RemoteCommand::RuntimeChatPrompt(prompt) => {
171 match app.remote_control.apply_runtime_chat_prompt(&prompt).await {
172 Ok(()) => app
173 .remote_control
174 .acknowledge(&run_id, seq, &command, "applied", None),
175 Err(error) => app.remote_control.acknowledge(
176 &run_id,
177 seq,
178 &command,
179 "failed",
180 Some(error),
181 ),
182 }
183 }
184 crate::remote_control::RemoteCommand::Prompt { turn_id, prompt } => {
185 if app.is_loading || app.dispatch_in_flight {
186 app.remote_control.acknowledge(
187 &run_id,
188 seq,
189 &command,
190 "failed",
191 Some(
192 "A turn is already running; the next prompt starts when it finishes."
193 .to_string(),
194 ),
195 );
196 continue;
197 }
198 app.remote_control
199 .upload_snapshot(&run_id, &app.api_messages);
200 if let Err(error) = app.remote_control.activate_prompt(&run_id, &turn_id) {
201 app.remote_control.acknowledge(
202 &run_id,
203 seq,
204 &command,
205 "failed",
206 Some(error),
207 );
208 continue;
209 }
210 let message = QueuedMessage::new(prompt, None);
211 app.remote_control.set_applying_remote_command(true);
212 let result = dispatch_user_message_with_recovery(
213 app,
214 config,
215 engine_handle,
216 message,
217 DispatchRecovery::Immediate,
218 )
219 .await;
220 app.remote_control.set_applying_remote_command(false);
221 match result {
222 Ok(()) if app.is_loading || app.dispatch_in_flight => {
223 app.remote_control
224 .acknowledge(&run_id, seq, &command, "applied", None);
225 }
226 Ok(()) => {
227 app.remote_control.fail_active_dispatch(
228 "The remote prompt was blocked before dispatch.",
229 );
230 app.remote_control.acknowledge(
231 &run_id,
232 seq,
233 &command,
234 "failed",
235 Some(
236 "The remote prompt was blocked before dispatch."
237 .to_string(),
238 ),
239 );
240 }
241 Err(error) => {
242 app.remote_control.fail_active_dispatch(&error.to_string());
243 app.remote_control.acknowledge(
244 &run_id,
245 seq,
246 &command,
247 "failed",
248 Some(error.to_string()),
249 );
250 }
251 }
252 }
253 crate::remote_control::RemoteCommand::Approval { gate, approved } => {
254 let Some(tool_id) = app.remote_control.take_pending_approval(&gate) else {
255 app.remote_control.acknowledge(
256 &run_id,
257 seq,
258 &command,
259 "failed",
260 Some("This approval is no longer pending.".to_string()),
261 );
262 continue;
263 };
264 let result = if approved {
265 engine_handle.approve_tool_call(tool_id.clone()).await
266 } else {
267 engine_handle.deny_tool_call(tool_id.clone()).await
268 };
269 match result {
270 Ok(()) => {
271 app.retire_action_notices(Some(&tool_id));
272 // First decision wins: the web answered this
273 // gate, so dismiss exactly the matching card —
274 // never an unrelated approval that happens to
275 // be on top (concurrent approvals, fleet).
276 if app.view_stack.top_matches_approval_gate(&gate) {
277 app.view_stack.pop();
278 app.needs_redraw = true;
279 }
280 let (message_id, level) = if approved {
281 (
282 MessageId::NotificationWebApproved,
283 StatusToastLevel::Success,
284 )
285 } else {
286 (MessageId::NotificationWebDenied, StatusToastLevel::Warning)
287 };
288 app.push_status_toast_record(
289 StatusToast::new(app.tr(message_id), level, Some(5_000))
290 .for_event(format!("web-decision:{tool_id}")),
291 );
292 app.remote_control
293 .acknowledge(&run_id, seq, &command, "applied", None);
294 }
295 Err(error) => app.remote_control.acknowledge(
296 &run_id,
297 seq,
298 &command,
299 "failed",
300 Some(error.to_string()),
301 ),
302 }
303 }
304 crate::remote_control::RemoteCommand::Control {
305 runtime_chat: Some(scope),
306 turn_id: Some(turn_id),
307 ..
308 } => {
309 match app
310 .remote_control
311 .interrupt_runtime_chat(&run_id, &scope, &turn_id)
312 .await
313 {
314 Ok(()) => app
315 .remote_control
316 .acknowledge(&run_id, seq, &command, "applied", None),
317 Err(error) => app.remote_control.acknowledge(
318 &run_id,
319 seq,
320 &command,
321 "failed",
322 Some(error),
323 ),
324 }
325 }
326 crate::remote_control::RemoteCommand::Control { turn_id, .. } => {
327 let exact_active_turn = turn_id.as_deref().is_some_and(|turn_id| {
328 app.remote_control.active_turn_matches(&run_id, turn_id)
329 });
330 if !exact_active_turn {
331 app.remote_control.acknowledge(
332 &run_id,
333 seq,
334 &command,
335 "failed",
336 Some("This turn no longer owns active Work.".to_string()),
337 );
338 continue;
339 }
340 engine_handle.cancel();
341 mark_active_turn_cancelled_locally(app);
342 app.remote_control
343 .acknowledge(&run_id, seq, &command, "applied", None);
344 }
345 }
346 }
347 }
348 }
349 // A Connected event and the local approval decision may be drained in the
350 // same UI iteration. Re-check after the event batch so the current turn is
351 // attached without waiting for another key or frame.
352 changed |= try_attach_active_local_turn_to_remote(app);
353 Ok(changed)
354 }
355
356 fn local_turn_is_active(app: &App) -> bool {
357 app.is_loading
358 || app.dispatch_in_flight
359 || matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
360 }
361
362 /// Attach `/rc` to the current local turn only after the server has supplied
363 /// a real run id and no pre-attachment approval card still owns the decision.
364 /// There is no await between the state check and the controller mutation, so a
365 /// terminal event cannot race this single-threaded ownership transition.
366 /// Attach `/rc` to the current local turn only after the server has supplied
367 /// a real run id and no pre-attachment approval card still owns the decision.
368 /// There is no await between the state check and the controller mutation, so a
369 /// terminal event cannot race this single-threaded ownership transition.
370 fn try_attach_active_local_turn_to_remote(app: &mut App) -> bool {
371 if app.remote_control.runtime_chat_blocks_local_dispatch() {
372 return false;
373 }
374 if !local_turn_is_active(app) {
375 // A dispatch can fail before its typed TurnStarted receipt. In that
376 // case there is no turn to hand off and the connected attachment is
377 // simply idle, so do not strand a synthetic active lease.
378 return app.remote_control.release_unstarted_local_turn();
379 }
380 if app
381 .view_stack
382 .contains_kind(crate::tui::views::ModalKind::Approval)
383 {
384 return false;
385 }
386 // `runtime_turn_id` intentionally survives the end of a turn for saved
387 // receipts. It is authoritative for this handoff only while the matching
388 // typed status is still in progress; a new dispatch otherwise parks until
389 // its own TurnStarted arrives instead of binding the previous turn id.
390 let turn_id = if matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) {
391 app.runtime_turn_id.as_deref()
392 } else {
393 None
394 };
395 app.remote_control.attach_current_local_turn(turn_id)
396 }
397
398 pub(crate) fn start_remote_control_session(app: &mut App, config: &Config) {
399 let session_id = app
400 .current_session_id
401 .clone()
402 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
403 app.current_session_id = Some(session_id.clone());
404 // The target is the folder, not the session: repeated `/rc` runs in the
405 // same folder reuse one enrollment grant instead of minting a new one.
406 let target_ref = crate::remote_control::target_ref(&app.workspace);
407 let workspace_label = app
408 .workspace
409 .file_name()
410 .and_then(|value| value.to_str())
411 .filter(|value| !value.is_empty())
412 .unwrap_or("Codewhale session")
413 .to_string();
414 let git_remote = crate::remote_control::observed_git_repo(&app.workspace);
415 let runtime_commit = option_env!("CODEWHALE_BUILD_COMMIT")
416 .unwrap_or("")
417 .to_string();
418 // The crash-recoverable delivery journal is mandatory outside tests: it is
419 // what lets an interrupted session prove which terminal/approval events
420 // never reached the account before handing the session back.
421 let journal_dir = match codewhale_config::codewhale_home() {
422 Ok(home) => home.join("remote-control"),
423 Err(_) => {
424 let error =
425 "Remote control needs a writable Codewhale home directory for its delivery journal."
426 .to_string();
427 app.status_message = Some(error.clone());
428 app.push_status_toast(error, StatusToastLevel::Error, Some(12_000));
429 return;
430 }
431 };
432 if let Err(error) = app.remote_control.prepare_remote_control_session_journal(
433 &journal_dir,
434 &target_ref,
435 &session_id,
436 ) {
437 app.push_status_toast(error, StatusToastLevel::Error, Some(12_000));
438 return;
439 }
440 if let Err(error) = app.remote_control.configure_runtime_chat(
441 config.clone(),
442 std::sync::Arc::clone(&app.plugin_registry),
443 journal_dir.join("runtime-chat"),
444 target_ref.clone(),
445 session_id.clone(),
446 ) {
447 app.push_status_toast(error, StatusToastLevel::Error, Some(12_000));
448 return;
449 }
450 match app
451 .remote_control
452 .start(crate::remote_control::RemoteStart {
453 workspace_label,
454 target_ref,
455 session_id,
456 runtime_version: env!("CARGO_PKG_VERSION").to_string(),
457 runtime_commit,
458 journal_dir: Some(journal_dir),
459 git_remote,
460 }) {
461 Ok(()) => {
462 let status = app.remote_control.status_line();
463 app.status_message = Some(status.clone());
464 app.sticky_status = Some(StatusToast::new(status, StatusToastLevel::Warning, None));
465 }
466 Err(error) => {
467 app.status_message = Some(error.clone());
468 app.push_status_toast(error, StatusToastLevel::Error, Some(12_000));
469 }
470 }
471 }
472
472 lines RUST