Skip to main content

netget/client/
command_support.rs

1//! Generic adoption helpers for the client command channel.
2//!
3//! A client protocol opts into injected commands (the dashboard's \[send\]
4//! button, and any future programmatic control) with a ~10-line diff to its
5//! connection loop:
6//!
7//! ```ignore
8//! let mut command_rx = command_support::register_command_channel(&app_state, client_id).await;
9//! // ... inside the read loop:
10//! tokio::select! {
11//!     read = read_half.read(&mut buffer) => { /* existing arms unchanged */ }
12//!     Some(cmd) = command_rx.recv() => {
13//!         if command_support::handle_stream_client_command(
14//!             &MyClientProtocol, &write_half_arc, cmd, client_id, &app_state, &status_tx,
15//!         ).await { break; }
16//!     }
17//! }
18//! ```
19//!
20//! `handle_stream_client_command` is fully generic for split-stream clients:
21//! it executes the action through the protocol's own `execute_action` (the
22//! same machinery LLM-produced actions use), writes `SendData` results to the
23//! write half, and replies with a [`ClientSendOutcome`]. A protocol whose
24//! vocabulary produces `Custom` results (IRC's send_privmsg, for instance)
25//! writes its own arm body and uses [`reply`] directly.
26//!
27//! Concurrency note: the command arm runs in the same single task as the read
28//! loop, so a command never races the loop's own writes. A command arriving
29//! while the loop is mid-LLM-call simply waits its turn in the bounded
30//! channel; user injection is deliberately independent of the protocol's
31//! Idle/Processing state machine.
32//!
33//! Cancellation-safety note for adopters: `AsyncReadExt::read` is
34//! cancellation-safe, so wrapping it in `tokio::select!` with the command arm
35//! does not lose data.
36
37use std::sync::Arc;
38
39use tokio::io::{AsyncWrite, AsyncWriteExt};
40use tokio::sync::{mpsc, Mutex};
41use tracing::{error, info};
42
43use crate::llm::actions::client_trait::{Client, ClientActionResult};
44use crate::state::client_handles::{
45    ClientCommand, ClientHandle, ClientSendOutcome, CLIENT_COMMAND_CAPACITY,
46};
47use crate::state::{AccessLogOwner, AppState, ClientId};
48
49/// Create and register the command channel for a running client. Call once the
50/// connection is established; move the returned receiver into the loop task.
51pub async fn register_command_channel(
52    state: &AppState,
53    client_id: ClientId,
54) -> mpsc::Receiver<ClientCommand> {
55    let (command_tx, command_rx) = mpsc::channel(CLIENT_COMMAND_CAPACITY);
56    state
57        .register_client_handle(client_id, ClientHandle { command_tx })
58        .await;
59    command_rx
60}
61
62/// Reply on a command's oneshot, tolerating a caller that stopped listening.
63pub fn reply(command: ClientCommand, outcome: anyhow::Result<ClientSendOutcome>) {
64    let _ = command.reply_tx.send(outcome);
65}
66
67/// Generic command-arm body for split-stream clients. Executes the action,
68/// maps the result to wire writes, records a client access-log entry, and
69/// replies on the command's oneshot.
70///
71/// Returns `true` when the action requested disconnect and the loop should
72/// break (the reply is sent before returning).
73pub async fn handle_stream_client_command<W, P>(
74    protocol: &P,
75    write_half: &Arc<Mutex<W>>,
76    command: ClientCommand,
77    client_id: ClientId,
78    state: &AppState,
79    status_tx: &mpsc::UnboundedSender<String>,
80) -> bool
81where
82    W: AsyncWrite + Unpin,
83    P: Client + ?Sized,
84{
85    let action = command.action.clone();
86    let (outcome, should_break) = execute_command_action(protocol, write_half, &action).await;
87
88    // Record the injected action in the access log so it shows up in the
89    // client's request pane exactly like LLM-produced traffic.
90    let outcome_json = match &outcome {
91        Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
92        Err(e) => serde_json::json!({"error": e.to_string()}),
93    };
94    state
95        .record_access_log(
96            AccessLogOwner::Client(client_id.as_u32()),
97            protocol.protocol_name(),
98            None,
99            "injected_action",
100            action,
101            vec![outcome_json],
102        )
103        .await;
104
105    match &outcome {
106        Ok(ClientSendOutcome::Sent { bytes_sent }) => {
107            info!(
108                "Client {} executed injected action ({} bytes sent)",
109                client_id, bytes_sent
110            );
111        }
112        Ok(ClientSendOutcome::Disconnected) => {
113            info!("Client {} disconnecting on injected action", client_id);
114        }
115        Ok(_) => {}
116        Err(e) => {
117            error!("Client {} injected action failed: {}", client_id, e);
118            let _ = status_tx.send(format!(
119                "[WARN] Client {} injected action failed: {}",
120                client_id, e
121            ));
122        }
123    }
124    let _ = status_tx.send("__UPDATE_UI__".to_string());
125
126    reply(command, outcome);
127    should_break
128}
129
130/// Execute one action and fold its (possibly `Multiple`) results into a single
131/// outcome. Separated from the reply/logging so it can recurse.
132async fn execute_command_action<W, P>(
133    protocol: &P,
134    write_half: &Arc<Mutex<W>>,
135    action: &serde_json::Value,
136) -> (anyhow::Result<ClientSendOutcome>, bool)
137where
138    W: AsyncWrite + Unpin,
139    P: Client + ?Sized,
140{
141    let result = match protocol.execute_action(action.clone()) {
142        Ok(result) => result,
143        Err(e) => {
144            return (
145                Ok(ClientSendOutcome::Rejected {
146                    error: e.to_string(),
147                }),
148                false,
149            )
150        }
151    };
152
153    let mut bytes_sent = 0usize;
154    let mut disconnect = false;
155    let mut details: Vec<String> = Vec::new();
156
157    // Flatten Multiple one level deep — that is the only nesting the enum's
158    // own producers use.
159    let results = match result {
160        ClientActionResult::Multiple(results) => results,
161        single => vec![single],
162    };
163
164    for result in results {
165        match result {
166            ClientActionResult::SendData(bytes) => {
167                let mut guard = write_half.lock().await;
168                if let Err(e) = guard.write_all(&bytes).await {
169                    return (Err(anyhow::anyhow!("write failed: {e}")), false);
170                }
171                if let Err(e) = guard.flush().await {
172                    return (Err(anyhow::anyhow!("flush failed: {e}")), false);
173                }
174                bytes_sent += bytes.len();
175            }
176            ClientActionResult::Disconnect => disconnect = true,
177            ClientActionResult::WaitForMore => details.push("wait_for_more".to_string()),
178            ClientActionResult::NoAction => details.push("no_action".to_string()),
179            ClientActionResult::Custom { name, .. } => {
180                // The generic arm cannot run protocol-specific machinery;
181                // protocols with Custom vocabularies need a bespoke arm body.
182                details.push(format!(
183                    "custom result '{name}' not executed by generic arm"
184                ));
185            }
186            ClientActionResult::Multiple(_) => {
187                details.push("nested multiple ignored".to_string());
188            }
189        }
190    }
191
192    let outcome = if disconnect {
193        ClientSendOutcome::Disconnected
194    } else if bytes_sent > 0 {
195        ClientSendOutcome::Sent { bytes_sent }
196    } else {
197        ClientSendOutcome::Executed {
198            detail: if details.is_empty() {
199                "executed".to_string()
200            } else {
201                details.join(", ")
202            },
203        }
204    };
205    (Ok(outcome), disconnect)
206}