netget/pipe/mod.rs
1//! Pipes: connect one instance's events to another instance's input.
2//!
3//! Today every server/client is an island. A *pipe* is a deterministic tap that
4//! forwards one server's network events into another server's input, so an
5//! operator (or the LLM, via a `create_pipe` action) can wire services together
6//! without an LLM round-trip per hop.
7//!
8//! The wiring is data, not code:
9//!
10//! ```json
11//! { "from": <server_id>, "on": "http_request",
12//! "to": <server_id>, "as": "send_tcp_data",
13//! "map": { "data": "{method} {path}\n" } }
14//! ```
15//!
16//! When server `from` raises the `on` event, the `map` templates are rendered
17//! against the event's structured data (`{method}`, `{path}`, `{headers.host}`,
18//! …) and the resulting payload is delivered into server `to`. This reuses the
19//! existing event system ([`crate::protocol::event_type::Event`], tapped once in
20//! `call_llm`) rather than adding any per-protocol glue.
21//!
22//! ## Delivery model (first slice)
23//!
24//! A server has no *inbound* action executor — its input arrives on a real
25//! socket — so a pipe feeds a server sink by opening a short TCP connection to
26//! the target's listen address and writing the mapped payload bytes. The payload
27//! comes from the mapped `data` field, honouring an optional `encoding`
28//! (`"utf8"` default, or `"hex"`), exactly like `send_tcp_data`. This works for
29//! any TCP-listening sink (TCP, and by raw bytes any text protocol); richer
30//! per-protocol handshakes and *client* sinks (delivered through their own
31//! outbound executor) are deliberately left as future work — see the module-level
32//! notes in the task report.
33//!
34//! ## Safety
35//!
36//! * **Cycle refusal.** Adding a pipe whose graph edge `from -> to` would close a
37//! loop (including the self-loop `A -> A`) is refused by [`would_create_cycle`],
38//! so the obvious `A -> B -> A` flood cannot be declared.
39//! * **Bounded fan-out.** Deliveries run under a process-wide semaphore
40//! ([`MAX_INFLIGHT_DELIVERIES`]). A high-rate source feeding a slow sink does
41//! not queue without bound: once the in-flight cap is reached, further
42//! deliveries are **dropped with a `warn!`**, never buffered, so memory stays
43//! bounded. Per-payload size is capped at [`MAX_PAYLOAD_BYTES`].
44//! * **Lifecycle.** Pipes touching a server are torn down when that server
45//! closes, mirroring scheduled-task scoping (see
46//! `AppStateInner::teardown_server`).
47
48use crate::protocol::event_type::Event;
49use crate::state::app_state::AppState;
50use crate::state::server::ServerStatus;
51use crate::state::ServerId;
52use anyhow::{anyhow, bail, Result};
53use serde::{Deserialize, Serialize};
54use serde_json::Value;
55use std::collections::BTreeMap;
56use std::io::Write;
57use std::net::SocketAddr;
58use std::sync::{Arc, LazyLock};
59use std::time::Duration;
60use tokio::io::AsyncWriteExt;
61use tokio::sync::Semaphore;
62use tracing::{debug, warn};
63
64/// Maximum concurrent in-flight pipe deliveries, process-wide. Deliveries beyond
65/// this are dropped-with-log rather than queued, so a slow sink cannot make a
66/// fast source exhaust memory.
67pub const MAX_INFLIGHT_DELIVERIES: usize = 128;
68
69/// Cap on a single rendered payload. A pipe is for log lines and small records,
70/// not bulk transfer.
71pub const MAX_PAYLOAD_BYTES: usize = 64 * 1024;
72
73/// Limit the intermediate template representation as well as the decoded payload.
74/// This allows separated hex while preventing repeated substitutions from allocating
75/// arbitrarily large strings before the payload limit can run.
76pub const MAX_RENDERED_BYTES: usize = 1024 * 1024;
77
78const DELIVERY_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
79const DELIVERY_WRITE_TIMEOUT: Duration = Duration::from_secs(2);
80
81/// Process-wide bound on concurrent deliveries. A single NetGet process owns all
82/// pipes, so a global cap is exactly the right granularity.
83static PIPE_INFLIGHT: LazyLock<Arc<Semaphore>> =
84 LazyLock::new(|| Arc::new(Semaphore::new(MAX_INFLIGHT_DELIVERIES)));
85
86/// Identifier for a pipe. Allocated from the same unified counter as servers and
87/// clients so ids never collide across kinds.
88pub type PipeId = u32;
89
90/// A declared pipe: forward `from`'s `on` events into `to` as `as_action`,
91/// rendering `map` templates against the event data.
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93pub struct PipeSpec {
94 /// Assigned pipe id.
95 pub id: PipeId,
96 /// Source server id whose events are tapped.
97 pub from: u32,
98 /// Event type id to tap, e.g. `"http_request"`.
99 pub on: String,
100 /// Target server id that receives the mapped input.
101 pub to: u32,
102 /// Action the mapping emulates on the target, e.g. `"send_tcp_data"`.
103 /// Advisory for the socket-delivery slice: the bytes come from the mapped
104 /// `data`/`encoding` fields regardless of the action name.
105 #[serde(rename = "as")]
106 pub as_action: String,
107 /// Field -> template. Templates reference source event fields with
108 /// `{field}` / `{dotted.path}`.
109 pub map: BTreeMap<String, String>,
110}
111
112/// Would adding edge `from -> to` create a directed cycle among `existing`
113/// pipes? Detects the self-loop `from == to` and any `to ->* from` path.
114pub fn would_create_cycle(existing: &[PipeSpec], from: u32, to: u32) -> bool {
115 if from == to {
116 return true;
117 }
118 // Reachable set from `to` following existing from->to edges; a cycle forms
119 // iff `from` is already reachable from `to`.
120 let mut stack = vec![to];
121 let mut seen = vec![to];
122 while let Some(node) = stack.pop() {
123 for e in existing.iter().filter(|e| e.from == node) {
124 if e.to == from {
125 return true;
126 }
127 if !seen.contains(&e.to) {
128 seen.push(e.to);
129 stack.push(e.to);
130 }
131 }
132 }
133 false
134}
135
136/// Render a `{field}` / `{dotted.path}` template against structured event data.
137/// A missing field renders empty (and is not an error — the payload is still
138/// well-formed). Literal braces are not supported in this first slice.
139struct RenderBuffer(Vec<u8>);
140
141impl Write for RenderBuffer {
142 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
143 if bytes.len() > MAX_RENDERED_BYTES.saturating_sub(self.0.len()) {
144 return Err(std::io::Error::other(format!(
145 "pipe rendered mapping exceeds the {MAX_RENDERED_BYTES}-byte cap"
146 )));
147 }
148 self.0.extend_from_slice(bytes);
149 Ok(bytes.len())
150 }
151
152 fn flush(&mut self) -> std::io::Result<()> {
153 Ok(())
154 }
155}
156
157fn render_template(mut tmpl: &str, data: &Value) -> Result<String> {
158 let mut out = RenderBuffer(Vec::new());
159 while let Some(open) = tmpl.find('{') {
160 out.write_all(tmpl[..open].as_bytes())?;
161 let rest = &tmpl[open + 1..];
162 let Some(close) = rest.find('}') else {
163 // Preserve an unterminated placeholder verbatim.
164 out.write_all(tmpl[open..].as_bytes())?;
165 return Ok(String::from_utf8(out.0)?);
166 };
167 let mut value = Some(data);
168 for segment in rest[..close].trim().split('.') {
169 value = value.and_then(|v| v.get(segment));
170 }
171 match value {
172 Some(Value::String(value)) => out.write_all(value.as_bytes())?,
173 None | Some(Value::Null) => {}
174 // Serialize directly into the bounded buffer; to_string() would allocate
175 // an entire arbitrarily large object before applying the limit.
176 Some(value) => serde_json::to_writer(&mut out, value)?,
177 }
178 tmpl = &rest[close + 1..];
179 }
180 out.write_all(tmpl.as_bytes())?;
181 Ok(String::from_utf8(out.0)?)
182}
183
184/// Decode a rendered `data` string into wire bytes per `encoding`.
185///
186/// Mirrors `send_tcp_data`: `"utf8"` (default) sends the characters as-is,
187/// `"hex"` decodes hex (tolerating whitespace, `:` separators and a leading
188/// `0x`). Any other value is an error rather than a silent guess.
189fn decode_payload(data: &str, encoding: &str) -> Result<Vec<u8>> {
190 match encoding {
191 "utf8" | "" => Ok(data.as_bytes().to_vec()),
192 "hex" => {
193 let cleaned: String = data
194 .trim()
195 .trim_start_matches("0x")
196 .chars()
197 .filter(|c| !c.is_ascii_whitespace() && *c != ':')
198 .collect();
199 hex::decode(&cleaned).map_err(|e| {
200 anyhow!(
201 "pipe payload declared encoding \"hex\" but is not valid hex ({e}); \
202 for text, use \"utf8\" or omit encoding"
203 )
204 })
205 }
206 other => bail!(
207 "invalid pipe payload encoding {other:?}; valid values are \"utf8\" (default) and \"hex\""
208 ),
209 }
210}
211
212/// Render a spec's mapping into the payload bytes to deliver to a socket sink.
213///
214/// Requires the mapping to produce a `data` field (the bytes); `encoding` is
215/// optional. Kept separate from delivery so it is unit-testable without a socket.
216pub fn render_payload(spec: &PipeSpec, event_data: &Value) -> Result<Vec<u8>> {
217 let data_tmpl = spec.map.get("data").ok_or_else(|| {
218 anyhow!(
219 "pipe #{} map must produce a 'data' field to deliver to a socket sink",
220 spec.id
221 )
222 })?;
223 let data = render_template(data_tmpl, event_data)?;
224 let encoding = spec
225 .map
226 .get("encoding")
227 .map(|t| render_template(t, event_data))
228 .transpose()?
229 .unwrap_or_else(|| "utf8".to_string());
230 let bytes = decode_payload(&data, &encoding)?;
231 if bytes.len() > MAX_PAYLOAD_BYTES {
232 bail!(
233 "pipe #{} payload is {} bytes, over the {}-byte cap",
234 spec.id,
235 bytes.len(),
236 MAX_PAYLOAD_BYTES
237 );
238 }
239 Ok(bytes)
240}
241
242/// The deliverable address of a server sink: its listen address, but only while
243/// it is actually running.
244fn deliverable_addr(status: &ServerStatus, local_addr: Option<SocketAddr>) -> Option<SocketAddr> {
245 match status {
246 ServerStatus::Running => local_addr,
247 _ => None,
248 }
249}
250
251/// Open a short TCP connection to `addr` and write `payload`, with timeouts.
252async fn deliver(addr: SocketAddr, payload: &[u8]) -> Result<()> {
253 let mut stream = tokio::time::timeout(
254 DELIVERY_CONNECT_TIMEOUT,
255 tokio::net::TcpStream::connect(addr),
256 )
257 .await
258 .map_err(|_| anyhow!("connect to {addr} timed out"))??;
259
260 tokio::time::timeout(DELIVERY_WRITE_TIMEOUT, stream.write_all(payload))
261 .await
262 .map_err(|_| anyhow!("write to {addr} timed out"))??;
263 // Flush and shut down the write side so the sink sees a clean message.
264 let _ = stream.flush().await;
265 let _ = stream.shutdown().await;
266 Ok(())
267}
268
269/// Tap point: called once per event in `call_llm`, before any handler runs, so a
270/// pipe fires regardless of how the source server answers its own peer (and even
271/// if the source's LLM call later fails).
272///
273/// This does no network I/O inline — it resolves the target address and renders
274/// the payload cheaply, then hands the actual connect+write to a bounded
275/// background task. It never blocks the source's response path and never holds a
276/// lock across an `.await` doing I/O.
277pub async fn dispatch_pipes(state: &AppState, from: ServerId, event: &Event) {
278 let specs = state.pipes_matching(from, event.id()).await;
279 if specs.is_empty() {
280 return;
281 }
282
283 for spec in specs {
284 let payload = match render_payload(&spec, &event.data) {
285 Ok(p) => p,
286 Err(e) => {
287 warn!("pipe #{}: mapping failed, dropping: {}", spec.id, e);
288 continue;
289 }
290 };
291
292 // Resolve the current target address (a target may have gone away since
293 // the pipe was declared — that is a clean runtime drop, not an error).
294 let to_id = ServerId::new(spec.to);
295 let addr = match state.get_server(to_id).await {
296 Some(s) => match deliverable_addr(&s.status, s.local_addr) {
297 Some(a) => a,
298 None => {
299 warn!(
300 "pipe #{}: target server #{} is not running/bound, dropping delivery",
301 spec.id, spec.to
302 );
303 continue;
304 }
305 },
306 None => {
307 warn!(
308 "pipe #{}: target server #{} no longer exists, dropping delivery",
309 spec.id, spec.to
310 );
311 continue;
312 }
313 };
314
315 // Bounded fan-out: acquire a permit or drop-with-log. Never queue.
316 let permit = match PIPE_INFLIGHT.clone().try_acquire_owned() {
317 Ok(p) => p,
318 Err(_) => {
319 warn!(
320 "pipe #{}: {} deliveries already in flight (cap), dropping this one",
321 spec.id, MAX_INFLIGHT_DELIVERIES
322 );
323 continue;
324 }
325 };
326
327 let pipe_id = spec.id;
328 tokio::spawn(async move {
329 let _permit = permit; // released when the delivery finishes
330 match deliver(addr, &payload).await {
331 Ok(()) => debug!(
332 "pipe #{}: delivered {} bytes to server #{} ({})",
333 pipe_id,
334 payload.len(),
335 to_id.as_u32(),
336 addr
337 ),
338 Err(e) => warn!("pipe #{}: delivery to {} failed: {}", pipe_id, addr, e),
339 }
340 });
341 }
342}
343
344/// Execute a `create_pipe` / `remove_pipe` action emitted by the LLM or an
345/// operator. Handled here (and dispatched from the action executor) rather than
346/// as a `CommonAction` variant, keeping the enum and its many exhaustive matches
347/// untouched. `default_from` is the server context the action ran in; a
348/// `create_pipe` may omit `from` to mean "this server".
349pub async fn execute_pipe_action(
350 action_name: &str,
351 action: &Value,
352 state: &AppState,
353 default_from: Option<ServerId>,
354) -> Result<()> {
355 match action_name {
356 "create_pipe" => {
357 let on = action
358 .get("on")
359 .and_then(|v| v.as_str())
360 .ok_or_else(|| anyhow!("create_pipe requires an 'on' event type"))?
361 .to_string();
362 let to = action
363 .get("to")
364 .and_then(json_as_u32)
365 .ok_or_else(|| anyhow!("create_pipe requires a numeric 'to' server id"))?;
366 let as_action = action
367 .get("as")
368 .and_then(|v| v.as_str())
369 .ok_or_else(|| anyhow!("create_pipe requires an 'as' action"))?
370 .to_string();
371 let from =
372 match action.get("from") {
373 Some(value) => ServerId::new(json_as_u32(value).ok_or_else(|| {
374 anyhow!("create_pipe requires a numeric 'from' server id")
375 })?),
376 None => default_from.ok_or_else(|| {
377 anyhow!("create_pipe has no 'from' and no server context to default to")
378 })?,
379 };
380 let map: BTreeMap<String, String> = match action.get("map") {
381 Some(Value::Object(obj)) => obj
382 .iter()
383 .map(|(k, v)| {
384 v.as_str()
385 .map(|s| (k.clone(), s.to_string()))
386 .ok_or_else(|| {
387 anyhow!("create_pipe map field {k:?} must be a string template")
388 })
389 })
390 .collect::<Result<_>>()?,
391 _ => bail!("create_pipe requires a 'map' object of string templates"),
392 };
393 let id = state
394 .add_pipe(from, on, ServerId::new(to), as_action, map)
395 .await?;
396 debug!("create_pipe: created pipe #{}", id);
397 Ok(())
398 }
399 "remove_pipe" => {
400 let pipe_id = action
401 .get("pipe_id")
402 .and_then(json_as_u32)
403 .ok_or_else(|| anyhow!("remove_pipe requires a numeric 'pipe_id'"))?;
404 if state.remove_pipe(pipe_id).await.is_none() {
405 bail!("remove_pipe: no pipe #{pipe_id}");
406 }
407 Ok(())
408 }
409 other => bail!("unknown pipe action {other:?}"),
410 }
411}
412
413/// Accept a u32 from either a JSON number or a numeric string (the LLM emits
414/// both).
415fn json_as_u32(v: &Value) -> Option<u32> {
416 v.as_u64()
417 .and_then(|n| u32::try_from(n).ok())
418 .or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
419}