返回 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 // Keep the gate pending until the engine takes the
255 // decision: a failed send leaves it retryable (U03-07).
256 let Some(tool_id) = app.remote_control.pending_approval_tool_id(&gate)
257 else {
258 app.remote_control.acknowledge(
259 &run_id,
260 seq,
261 &command,
262 "failed",
263 Some("This approval is no longer pending.".to_string()),
264 );
265 continue;
266 };
267 let result = if approved {
268 engine_handle.approve_tool_call(tool_id.clone()).await
269 } else {
270 engine_handle.deny_tool_call(tool_id.clone()).await
271 };
272 match result {
273 Ok(()) => {
274 let _ = app.remote_control.take_pending_approval(&gate);
275 app.retire_action_notices(Some(&tool_id));
276 // First decision wins: the web answered this
277 // gate, so dismiss exactly the matching card —
278 // never an unrelated approval that happens to
279 // be on top (concurrent approvals, fleet).
280 // The card may sit under another view
281 // (a child's card, a pager): remove it at
282 // any depth, and forget its pending entry.
283 if app.view_stack.remove_approval_for_gate(&gate) {
284 app.needs_redraw = true;
285 }
286 crate::tui::pending_requests::resolve(app, &tool_id);
287 let (message_id, level) = if approved {
288 (
289 MessageId::NotificationWebApproved,
290 StatusToastLevel::Success,
291 )
292 } else {
293 (MessageId::NotificationWebDenied, StatusToastLevel::Warning)
294 };
295 app.push_status_toast_record(
296 StatusToast::new(app.tr(message_id), level, Some(5_000))
297 .for_event(format!("web-decision:{tool_id}")),
298 );
299 app.remote_control
300 .acknowledge(&run_id, seq, &command, "applied", None);
301 }
302 Err(error) => app.remote_control.acknowledge(
303 &run_id,
304 seq,
305 &command,
306 "failed",
307 Some(error.to_string()),
308 ),
309 }
310 }
311 crate::remote_control::RemoteCommand::Control {
312 runtime_chat: Some(scope),
313 turn_id: Some(turn_id),
314 ..
315 } => {
316 match app
317 .remote_control
318 .interrupt_runtime_chat(&run_id, &scope, &turn_id)
319 .await
320 {
321 Ok(()) => app
322 .remote_control
323 .acknowledge(&run_id, seq, &command, "applied", None),
324 Err(error) => app.remote_control.acknowledge(
325 &run_id,
326 seq,
327 &command,
328 "failed",
329 Some(error),
330 ),
331 }
332 }
333 crate::remote_control::RemoteCommand::Control { turn_id, .. } => {
334 let exact_active_turn = turn_id.as_deref().is_some_and(|turn_id| {
335 app.remote_control.active_turn_matches(&run_id, turn_id)
336 });
337 if !exact_active_turn {
338 app.remote_control.acknowledge(
339 &run_id,
340 seq,
341 &command,
342 "failed",
343 Some("This turn no longer owns active Work.".to_string()),
344 );
345 continue;
346 }
347 engine_handle.cancel();
348 mark_active_turn_cancelled_locally(app);
349 app.remote_control
350 .acknowledge(&run_id, seq, &command, "applied", None);
351 }
352 }
353 }
354 }
355 }
356 // A Connected event and the local approval decision may be drained in the
357 // same UI iteration. Re-check after the event batch so the current turn is
358 // attached without waiting for another key or frame.
359 changed |= try_attach_active_local_turn_to_remote(app);
360 Ok(changed)
361 }
362
363 fn local_turn_is_active(app: &App) -> bool {
364 app.is_loading
365 || app.dispatch_in_flight
366 || matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
367 }
368
369 /// Attach `/rc` to the current local turn only after the server has supplied
370 /// a real run id and no pre-attachment approval card still owns the decision.
371 /// There is no await between the state check and the controller mutation, so a
372 /// terminal event cannot race this single-threaded ownership transition.
373 /// Attach `/rc` to the current local turn only after the server has supplied
374 /// a real run id and no pre-attachment approval card still owns the decision.
375 /// There is no await between the state check and the controller mutation, so a
376 /// terminal event cannot race this single-threaded ownership transition.
377 fn try_attach_active_local_turn_to_remote(app: &mut App) -> bool {
378 if app.remote_control.runtime_chat_blocks_local_dispatch() {
379 return false;
380 }
381 if !local_turn_is_active(app) {
382 // A dispatch can fail before its typed TurnStarted receipt. In that
383 // case there is no turn to hand off and the connected attachment is
384 // simply idle, so do not strand a synthetic active lease.
385 return app.remote_control.release_unstarted_local_turn();
386 }
387 if app
388 .view_stack
389 .contains_kind(crate::tui::views::ModalKind::Approval)
390 {
391 return false;
392 }
393 // `runtime_turn_id` intentionally survives the end of a turn for saved
394 // receipts. It is authoritative for this handoff only while the matching
395 // typed status is still in progress; a new dispatch otherwise parks until
396 // its own TurnStarted arrives instead of binding the previous turn id.
397 let turn_id = if matches!(app.runtime_turn_status.as_deref(), Some("in_progress")) {
398 app.runtime_turn_id.as_deref()
399 } else {
400 None
401 };
402 app.remote_control.attach_current_local_turn(turn_id)
403 }
404
405 pub(crate) fn start_remote_control_session(app: &mut App, config: &Config) {
406 let session_id = app
407 .current_session_id
408 .clone()
409 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
410 app.current_session_id = Some(session_id.clone());
411 // The target is the folder, not the session: repeated `/rc` runs in the
412 // same folder reuse one enrollment grant instead of minting a new one.
413 let target_ref = crate::remote_control::target_ref(&app.workspace);
414 let workspace_label = app
415 .workspace
416 .file_name()
417 .and_then(|value| value.to_str())
418 .filter(|value| !value.is_empty())
419 .unwrap_or("Codewhale session")
420 .to_string();
421 let git_remote = crate::remote_control::observed_git_repo(&app.workspace);
422 let runtime_commit = option_env!("CODEWHALE_BUILD_COMMIT")
423 .unwrap_or("")
424 .to_string();
425 // The crash-recoverable delivery journal is mandatory outside tests: it is
426 // what lets an interrupted session prove which terminal/approval events
427 // never reached the account before handing the session back.
428 let journal_dir = match codewhale_config::codewhale_home() {
429 Ok(home) => home.join("remote-control"),
430 Err(_) => {
431 let error =
432 "Remote control needs a writable Codewhale home directory for its delivery journal."
433 .to_string();
434 app.status_message = Some(error.clone());
435 app.push_status_toast(error, StatusToastLevel::Error, Some(12_000));
436 return;
437 }
438 };
439 if let Err(error) = app.remote_control.prepare_remote_control_session_journal(
440 &journal_dir,
441 &target_ref,
442 &session_id,
443 ) {
444 app.push_status_toast(error, StatusToastLevel::Error, Some(12_000));
445 return;
446 }
447 if let Err(error) = app.remote_control.configure_runtime_chat(
448 config.clone(),
449 std::sync::Arc::clone(&app.plugin_registry),
450 journal_dir.join("runtime-chat"),
451 target_ref.clone(),
452 session_id.clone(),
453 ) {
454 app.push_status_toast(error, StatusToastLevel::Error, Some(12_000));
455 return;
456 }
457 match app
458 .remote_control
459 .start(crate::remote_control::RemoteStart {
460 workspace_label,
461 target_ref,
462 session_id,
463 runtime_version: env!("CARGO_PKG_VERSION").to_string(),
464 runtime_commit,
465 journal_dir: Some(journal_dir),
466 git_remote,
467 }) {
468 Ok(()) => {
469 let status = app.remote_control.status_line();
470 app.status_message = Some(status.clone());
471 app.sticky_status = Some(StatusToast::new(status, StatusToastLevel::Warning, None));
472 }
473 Err(error) => {
474 app.status_message = Some(error.clone());
475 app.push_status_toast(error, StatusToastLevel::Error, Some(12_000));
476 }
477 }
478 }
479
479 lines RUST