| 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 |