netget/state/client_handles.rs
1//! Command channel into a **running** client's connection loop.
2//!
3//! # The problem this exists to solve
4//!
5//! `Client::execute_action()` is only ever called from inside each client's own
6//! connection loop — the task that owns the socket — and the only thing that
7//! produces actions for it is the LLM answering a network event. Nothing
8//! outside the loop (the dashboard's \[send\] button, a scheduled task, a
9//! future LLM `send_client_action`) can put an action on the wire.
10//!
11//! # The shape of the fix
12//!
13//! Unlike [`crate::state::server_handles`], this registry is **not**
14//! type-erased. What every caller wants from every client is identical —
15//! "here is an action JSON, execute it against the live connection" — and the
16//! `Client` trait already defines that uniform vocabulary. So the handle is a
17//! plain typed command channel. Each client's loop adds a `tokio::select!` arm
18//! receiving [`ClientCommand`]s (see `crate::client::command_support` for the
19//! generic arm body) and executes them with the same machinery it already uses
20//! for LLM-produced actions.
21//!
22//! The channel is **bounded** (capacity [`CLIENT_COMMAND_CAPACITY`]): commands
23//! are user-initiated and low-rate, and "client busy" backpressure is the
24//! correct failure mode — unlike the unbounded status channels, which carry
25//! fire-and-forget log lines.
26//!
27//! Lifetime is tied to the client: [`AppState::remove_client`] drops the
28//! handle, and a loop that exits drops its receiver, which makes any later
29//! send fail fast with a clear error rather than hang. Clients that have not
30//! adopted the channel simply never register — `has_client_handle` returns
31//! false and the UI greys out \[send\].
32
33use tokio::sync::{mpsc, oneshot};
34
35/// Bounded command-channel capacity per client.
36pub const CLIENT_COMMAND_CAPACITY: usize = 16;
37
38/// Outcome of injecting one action into a running client's connection loop.
39#[derive(Debug, Clone, serde::Serialize)]
40pub enum ClientSendOutcome {
41 /// Action executed; bytes were written and flushed to the wire.
42 Sent { bytes_sent: usize },
43 /// Action executed but produced no wire data (NoAction / WaitForMore /
44 /// a Custom result the protocol handled internally).
45 Executed { detail: String },
46 /// The client's protocol rejected the action (unknown type, bad params).
47 Rejected { error: String },
48 /// The action requested disconnect; the loop is shutting down.
49 Disconnected,
50}
51
52/// One injected command: the action JSON plus a best-effort reply slot.
53///
54/// A dropped `reply_tx` receiver is fine — fire-and-forget callers just don't
55/// listen for the outcome.
56pub struct ClientCommand {
57 pub action: serde_json::Value,
58 pub reply_tx: oneshot::Sender<anyhow::Result<ClientSendOutcome>>,
59}
60
61/// Handle to a running client's connection loop.
62#[derive(Clone)]
63pub struct ClientHandle {
64 pub command_tx: mpsc::Sender<ClientCommand>,
65}