Skip to main content

netget/client/ipp/
mod.rs

1//! IPP (Internet Printing Protocol) client implementation
2
3pub mod actions;
4
5pub use actions::IppClientProtocol;
6
7use anyhow::{Context, Result};
8use ipp::prelude::*;
9use std::net::SocketAddr;
10use std::sync::Arc;
11use tokio::sync::mpsc;
12use tracing::{error, info};
13
14use crate::client::ipp::actions::IPP_CLIENT_CONNECTED_EVENT;
15use crate::client::llm_budget::call_llm_for_client;
16use crate::llm::actions::client_trait::{Client, ClientActionResult};
17use crate::llm::ollama_client::OllamaClient;
18use crate::llm::ClientLlmResult;
19use crate::logging::emit::Log;
20use crate::protocol::Event;
21use crate::state::app_state::AppState;
22use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
23use crate::state::{AccessLogOwner, ClientId, ClientStatus};
24
25/// One completed IPP operation.
26///
27/// Split out of the operations below so the injected-command loop can await the
28/// IPP-over-HTTP round-trip - and report a truthful outcome - without also awaiting the LLM
29/// call the `ipp_response_received` event triggers.
30pub struct IppExchange {
31    pub operation: &'static str,
32    pub success: bool,
33    pub summary: String,
34    pub response_data: serde_json::Value,
35}
36
37/// What one executed action did.
38enum Applied {
39    /// The action ran; `detail` says what it did.
40    Executed(String),
41    /// The action asked to end the session.
42    Disconnect,
43}
44
45/// How an IPP operation is issued.
46#[derive(Clone, Copy)]
47enum Dispatch {
48    /// Spawn the whole operation and return immediately. Used by the connected-event
49    /// handler, which runs inline in `connect()` and must not block client creation.
50    Spawn,
51    /// Await the IPP exchange so the caller can report what actually happened, then raise
52    /// the response event from its own registered task. Used by the injected-command loop,
53    /// so a parked manual handler cannot wedge it for the length of a human's think time.
54    Await,
55}
56
57/// IPP client that connects to remote IPP print servers
58pub struct IppClient;
59
60impl IppClient {
61    /// Connect to an IPP print server with integrated LLM actions
62    pub async fn connect_with_llm_actions(
63        remote_addr: String,
64        printer_path: Option<String>,
65        llm_client: OllamaClient,
66        app_state: Arc<AppState>,
67        status_tx: mpsc::UnboundedSender<String>,
68        client_id: ClientId,
69    ) -> Result<SocketAddr> {
70        info!("IPP client {} initialized for {}", client_id, remote_addr);
71
72        // Parse the remote address to construct IPP URI
73        // IPP typically uses http://host:631/printers/printer-name format
74        let base = if remote_addr.starts_with("http://") || remote_addr.starts_with("https://") {
75            remote_addr.clone()
76        } else if remote_addr.starts_with("ipp://") {
77            // Convert ipp:// to http://
78            remote_addr.replace("ipp://", "http://")
79        } else {
80            // Default to http:// with IPP default port 631
81            format!("http://{}", remote_addr)
82        };
83
84        // The declared `printer_path` parameter, which nothing used to read. IPP addresses a
85        // queue rather than a host, so this is what decides *which printer* is talked to.
86        //
87        // An address that already carries a path wins: the operator spelled the whole URI out,
88        // and silently appending to it would produce `/printers/a/printers/b`. Joining is done
89        // by hand rather than with a URL crate because the only thing being decided is one
90        // slash, and pulling in a parser to decide it would be the larger change.
91        let uri_str = match printer_path {
92            Some(path) if !path_already_present(&base) => {
93                format!(
94                    "{}/{}",
95                    base.trim_end_matches('/'),
96                    path.trim_start_matches('/')
97                )
98            }
99            _ => base,
100        };
101
102        // Store URI in protocol_data
103        app_state
104            .with_client_mut(client_id, |client| {
105                client.set_protocol_field("ipp_uri".to_string(), serde_json::json!(uri_str));
106                client
107                    .set_protocol_field("ipp_client".to_string(), serde_json::json!("initialized"));
108            })
109            .await;
110
111        // Update status
112        app_state
113            .update_client_status(client_id, ClientStatus::Connected)
114            .await;
115        let _ = status_tx.send(format!(
116            "[CLIENT] IPP client {} ready for {}",
117            client_id, uri_str
118        ));
119        let _ = status_tx.send("__UPDATE_UI__".to_string());
120
121        // Command channel for injected actions (the dashboard's [ send ] row).
122        // Registered and *served* before the ipp_connected LLM call below: a
123        // dashboard-created client defaults to a `*` -> manual routing rule, so that
124        // call can park for minutes and [ send ] has to work for the whole park.
125        let command_rx =
126            crate::client::command_support::register_command_channel(&app_state, client_id).await;
127
128        // The command loop replaces the old 5s "is the client gone yet?" poll: when the
129        // client is removed its handle is dropped, the channel closes and `recv()`
130        // returns None, so the loop notices removal immediately instead of up to 5s later.
131        // Registered with AppState so stop_client can abort it —
132        // dropping a JoinHandle only detaches it in Tokio.
133        let command_task = tokio::spawn(Self::command_loop(
134            command_rx,
135            client_id,
136            app_state.clone(),
137            llm_client.clone(),
138            status_tx.clone(),
139        ));
140        app_state
141            .register_client_task(client_id, command_task)
142            .await;
143
144        // Call LLM with ipp_connected event
145        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
146            let event = Event::new(
147                &IPP_CLIENT_CONNECTED_EVENT,
148                serde_json::json!({
149                    "ipp_uri": uri_str.clone(),
150                }),
151            );
152
153            let protocol = Arc::new(crate::client::ipp::actions::IppClientProtocol::new());
154
155            match call_llm_for_client(
156                &llm_client,
157                &app_state,
158                client_id.to_string(),
159                &instruction,
160                &String::new(),
161                Some(&event),
162                protocol.as_ref(),
163                &status_tx,
164            )
165            .await
166            {
167                Ok(result) => {
168                    // Execute actions from LLM response
169                    for action in result.actions {
170                        match protocol.execute_action(action) {
171                            Ok(ClientActionResult::Disconnect) => {
172                                info!("LLM requested disconnect after connect");
173                                app_state.remove_client_handle(client_id).await;
174                                app_state
175                                    .update_client_status(client_id, ClientStatus::Disconnected)
176                                    .await;
177                                return Ok("0.0.0.0:0".parse().unwrap());
178                            }
179                            Ok(applied) => {
180                                // Dispatch::Spawn so connect() can return; the operation
181                                // itself runs through the same apply_action the dashboard's
182                                // injected commands use.
183                                match Self::apply_action(
184                                    client_id,
185                                    applied,
186                                    Dispatch::Spawn,
187                                    &app_state,
188                                    &llm_client,
189                                    &status_tx,
190                                )
191                                .await
192                                {
193                                    Ok(Applied::Executed(detail)) => {
194                                        info!("IPP client {} after connect: {}", client_id, detail)
195                                    }
196                                    Ok(Applied::Disconnect) => {
197                                        info!("IPP client {} disconnecting", client_id)
198                                    }
199                                    Err(e) => error!("IPP operation failed: {}", e),
200                                }
201                            }
202                            Err(e) => {
203                                error!("IPP client {} rejected action: {}", client_id, e);
204                            }
205                        }
206                    }
207                }
208                Err(e) => {
209                    error!("LLM error on ipp_connected event: {}", e);
210                }
211            }
212        }
213
214        // Return a dummy local address (IPP is HTTP-based, connectionless)
215        Ok("0.0.0.0:0".parse().unwrap())
216    }
217
218    /// Send Get-Printer-Attributes and hand the response to the LLM.
219    pub async fn get_printer_attributes(
220        client_id: ClientId,
221        app_state: Arc<AppState>,
222        llm_client: OllamaClient,
223        status_tx: mpsc::UnboundedSender<String>,
224    ) -> Result<()> {
225        let exchange =
226            Self::perform_get_printer_attributes(client_id, &app_state, &status_tx).await?;
227        Self::notify_response(client_id, exchange, app_state, llm_client, status_tx).await;
228        Ok(())
229    }
230
231    /// Send Get-Printer-Attributes only. No LLM involvement, so a caller can await this and
232    /// know exactly what the printer answered.
233    pub async fn perform_get_printer_attributes(
234        client_id: ClientId,
235        app_state: &AppState,
236        status_tx: &mpsc::UnboundedSender<String>,
237    ) -> Result<IppExchange> {
238        let uri_str = Self::get_uri(app_state, client_id).await?;
239        let uri: Uri = uri_str.parse().context("Invalid IPP URI")?;
240
241        info!(
242            "IPP client {} sending Get-Printer-Attributes to {}",
243            client_id, uri
244        );
245
246        let operation = IppOperationBuilder::get_printer_attributes(uri.clone()).build();
247        let client = AsyncIppClient::new(uri);
248
249        match client.send(operation).await {
250            Ok(response) => {
251                let status_code = response.header().status_code();
252                info!(
253                    "IPP client {} received response: status={:?}",
254                    client_id, status_code
255                );
256
257                // Extract printer attributes
258                let mut attributes = serde_json::Map::new();
259                if status_code.is_success() {
260                    if let Some(printer_attrs) = response
261                        .attributes()
262                        .groups_of(DelimiterTag::PrinterAttributes)
263                        .next()
264                    {
265                        for (_, attr) in printer_attrs.attributes() {
266                            attributes.insert(
267                                attr.name().to_string(),
268                                serde_json::json!(attr.value().to_string()),
269                            );
270                        }
271                    }
272                }
273
274                Ok(IppExchange {
275                    operation: "get_printer_attributes",
276                    success: status_code.is_success(),
277                    summary: format!("{:?}, {} attribute(s)", status_code, attributes.len()),
278                    response_data: serde_json::json!({
279                        "status_code": format!("{:?}", status_code),
280                        "attributes": attributes,
281                    }),
282                })
283            }
284            Err(e) => {
285                Log::new(Some(status_tx)).error(format!(
286                    "IPP client {} Get-Printer-Attributes failed: {}",
287                    client_id, e
288                ));
289                Err(e.into())
290            }
291        }
292    }
293
294    /// Send Print-Job and hand the response to the LLM.
295    pub async fn print_job(
296        client_id: ClientId,
297        job_name: String,
298        document_format: Option<String>,
299        document_data: Vec<u8>,
300        app_state: Arc<AppState>,
301        llm_client: OllamaClient,
302        status_tx: mpsc::UnboundedSender<String>,
303    ) -> Result<()> {
304        let exchange = Self::perform_print_job(
305            client_id,
306            job_name,
307            document_format,
308            document_data,
309            &app_state,
310            &status_tx,
311        )
312        .await?;
313        Self::notify_response(client_id, exchange, app_state, llm_client, status_tx).await;
314        Ok(())
315    }
316
317    /// Send Print-Job only. No LLM involvement.
318    pub async fn perform_print_job(
319        client_id: ClientId,
320        job_name: String,
321        document_format: Option<String>,
322        document_data: Vec<u8>,
323        app_state: &AppState,
324        status_tx: &mpsc::UnboundedSender<String>,
325    ) -> Result<IppExchange> {
326        let uri_str = Self::get_uri(app_state, client_id).await?;
327        let uri: Uri = uri_str.parse().context("Invalid IPP URI")?;
328
329        info!(
330            "IPP client {} sending Print-Job to {}: job={}, format={:?}, size={} bytes",
331            client_id,
332            uri,
333            job_name,
334            document_format,
335            document_data.len()
336        );
337
338        // Build Print-Job operation
339        // IppPayload needs a Read type, so we convert Vec<u8> to Cursor
340        let document_len = document_data.len();
341        let cursor = std::io::Cursor::new(document_data);
342        let payload = IppPayload::new(cursor);
343
344        let operation = IppOperationBuilder::print_job(uri.clone(), payload)
345            .job_title(&job_name)
346            .build();
347
348        let client = AsyncIppClient::new(uri);
349
350        match client.send(operation).await {
351            Ok(response) => {
352                let status_code = response.header().status_code();
353                info!(
354                    "IPP client {} Print-Job response: status={:?}",
355                    client_id, status_code
356                );
357
358                // Extract job attributes
359                let mut job_attrs = serde_json::Map::new();
360                if status_code.is_success() {
361                    if let Some(attrs) = response
362                        .attributes()
363                        .groups_of(DelimiterTag::JobAttributes)
364                        .next()
365                    {
366                        for (_, attr) in attrs.attributes() {
367                            job_attrs.insert(
368                                attr.name().to_string(),
369                                serde_json::json!(attr.value().to_string()),
370                            );
371                        }
372                    }
373                }
374
375                Ok(IppExchange {
376                    operation: "print_job",
377                    success: status_code.is_success(),
378                    summary: format!(
379                        "{:?} for a {}-byte document, {} job attribute(s)",
380                        status_code,
381                        document_len,
382                        job_attrs.len()
383                    ),
384                    response_data: serde_json::json!({
385                        "status_code": format!("{:?}", status_code),
386                        "job_name": job_name,
387                        "job_attributes": job_attrs,
388                    }),
389                })
390            }
391            Err(e) => {
392                Log::new(Some(status_tx))
393                    .error(format!("IPP client {} Print-Job failed: {}", client_id, e));
394                Err(e.into())
395            }
396        }
397    }
398
399    /// Send Get-Job-Attributes and hand the response to the LLM.
400    pub async fn get_job_attributes(
401        client_id: ClientId,
402        job_id: i32,
403        app_state: Arc<AppState>,
404        llm_client: OllamaClient,
405        status_tx: mpsc::UnboundedSender<String>,
406    ) -> Result<()> {
407        let exchange =
408            Self::perform_get_job_attributes(client_id, job_id, &app_state, &status_tx).await?;
409        Self::notify_response(client_id, exchange, app_state, llm_client, status_tx).await;
410        Ok(())
411    }
412
413    /// Send Get-Job-Attributes only. No LLM involvement.
414    pub async fn perform_get_job_attributes(
415        client_id: ClientId,
416        job_id: i32,
417        app_state: &AppState,
418        status_tx: &mpsc::UnboundedSender<String>,
419    ) -> Result<IppExchange> {
420        let uri_str = Self::get_uri(app_state, client_id).await?;
421        let uri: Uri = uri_str.parse().context("Invalid IPP URI")?;
422
423        info!(
424            "IPP client {} sending Get-Job-Attributes to {}: job_id={}",
425            client_id, uri, job_id
426        );
427
428        let operation = IppOperationBuilder::get_job_attributes(uri.clone(), job_id).build();
429        let client = AsyncIppClient::new(uri);
430
431        match client.send(operation).await {
432            Ok(response) => {
433                let status_code = response.header().status_code();
434                info!(
435                    "IPP client {} Get-Job-Attributes response: status={:?}",
436                    client_id, status_code
437                );
438
439                // Extract job attributes
440                let mut attributes = serde_json::Map::new();
441                if status_code.is_success() {
442                    if let Some(job_attrs) = response
443                        .attributes()
444                        .groups_of(DelimiterTag::JobAttributes)
445                        .next()
446                    {
447                        for (_, attr) in job_attrs.attributes() {
448                            attributes.insert(
449                                attr.name().to_string(),
450                                serde_json::json!(attr.value().to_string()),
451                            );
452                        }
453                    }
454                }
455
456                Ok(IppExchange {
457                    operation: "get_job_attributes",
458                    success: status_code.is_success(),
459                    summary: format!(
460                        "job {}: {:?}, {} attribute(s)",
461                        job_id,
462                        status_code,
463                        attributes.len()
464                    ),
465                    response_data: serde_json::json!({
466                        "status_code": format!("{:?}", status_code),
467                        "job_id": job_id,
468                        "attributes": attributes,
469                    }),
470                })
471            }
472            Err(e) => {
473                Log::new(Some(status_tx)).error(format!(
474                    "IPP client {} Get-Job-Attributes failed: {}",
475                    client_id, e
476                ));
477                Err(e.into())
478            }
479        }
480    }
481
482    /// Get IPP URI from client state
483    async fn get_uri(app_state: &AppState, client_id: ClientId) -> Result<String> {
484        app_state
485            .with_client_mut(client_id, |client| {
486                client
487                    .get_protocol_field("ipp_uri")
488                    .and_then(|v| v.as_str())
489                    .map(|s| s.to_string())
490            })
491            .await
492            .flatten()
493            .context("No IPP URI found in client state")
494    }
495
496    /// Hand a completed exchange to the LLM as an `ipp_response_received` event.
497    /// Run the actions the model returned for a response event.
498    ///
499    /// They were discarded (`actions: _`), so answering ipp_response_received did nothing at all --
500    /// the whole point of raising the event.
501    ///
502    /// Everything goes through the `perform_*` round-trips, which raise no event. That
503    /// bounds the loop -- a follow-up cannot trigger another response event and drive the
504    /// model in circles -- and is the only shape that compiles, since routing back through
505    /// the notifying entry points makes notify -> perform -> notify a self-referential
506    /// async chain rustc cannot prove `Send`.
507    async fn run_follow_ups(
508        client_id: ClientId,
509        actions: Vec<serde_json::Value>,
510        app_state: &AppState,
511        status_tx: &mpsc::UnboundedSender<String>,
512    ) {
513        use crate::llm::actions::client_trait::{Client, ClientActionResult};
514        let protocol = crate::client::ipp::actions::IppClientProtocol::new();
515        for action in actions {
516            let Ok(ClientActionResult::Custom { name, data }) =
517                protocol.execute_action(action.clone())
518            else {
519                continue;
520            };
521            let outcome: Result<()> = match name.as_str() {
522                "ipp_get_printer_attributes" => {
523                    Self::perform_get_printer_attributes(client_id, app_state, status_tx)
524                        .await
525                        .map(|_| ())
526                }
527                "ipp_print_job" => Self::perform_print_job(
528                    client_id,
529                    data["job_name"].as_str().unwrap_or("netget").to_string(),
530                    data["document_format"].as_str().map(|s| s.to_string()),
531                    data["document_data"]
532                        .as_str()
533                        .map(|s| s.as_bytes().to_vec())
534                        .unwrap_or_default(),
535                    app_state,
536                    status_tx,
537                )
538                .await
539                .map(|_| ()),
540                "ipp_get_job_attributes" => Self::perform_get_job_attributes(
541                    client_id,
542                    data["job_id"].as_i64().unwrap_or(0) as i32,
543                    app_state,
544                    status_tx,
545                )
546                .await
547                .map(|_| ()),
548                other => {
549                    info!(
550                        "IPP client {} follow-up '{}' has no non-notifying path; skipped",
551                        client_id, other
552                    );
553                    Ok(())
554                }
555            };
556            if let Err(e) = outcome {
557                error!("IPP client {} follow-up action failed: {}", client_id, e);
558            }
559        }
560    }
561
562    async fn notify_response(
563        client_id: ClientId,
564        exchange: IppExchange,
565        app_state: Arc<AppState>,
566        llm_client: OllamaClient,
567        status_tx: mpsc::UnboundedSender<String>,
568    ) {
569        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
570            return;
571        };
572
573        let protocol = Arc::new(crate::client::ipp::actions::IppClientProtocol::new());
574        let event = Event::new(
575            &crate::client::ipp::actions::IPP_CLIENT_RESPONSE_RECEIVED_EVENT,
576            serde_json::json!({
577                "operation": exchange.operation,
578                "success": exchange.success,
579                "response": exchange.response_data,
580            }),
581        );
582
583        let memory = app_state
584            .get_memory_for_client(client_id)
585            .await
586            .unwrap_or_default();
587
588        match call_llm_for_client(
589            &llm_client,
590            &app_state,
591            client_id.to_string(),
592            &instruction,
593            &memory,
594            Some(&event),
595            protocol.as_ref(),
596            &status_tx,
597        )
598        .await
599        {
600            Ok(ClientLlmResult {
601                actions,
602                memory_updates,
603            }) => {
604                // Update memory
605                if let Some(mem) = memory_updates {
606                    app_state.set_memory_for_client(client_id, mem).await;
607                }
608                Self::run_follow_ups(client_id, actions, &app_state, &status_tx).await;
609            }
610            Err(e) => {
611                error!("LLM error for IPP client {}: {}", client_id, e);
612            }
613        }
614    }
615
616    /// Apply one already-parsed action against the live IPP client.
617    ///
618    /// The single place IPP actions become IPP-over-HTTP requests, so an action injected
619    /// from the dashboard behaves exactly like one the model produced. Under
620    /// [`Dispatch::Await`] only the **network** half is awaited; the response event - and
621    /// the LLM call it makes - is raised from its own registered task afterwards, so a
622    /// `*` -> manual routing rule parking that event cannot wedge the command loop for the
623    /// length of a human's think time. The outcome stays truthful because it carries the
624    /// IPP status the printer actually returned, which is known before the event is raised.
625    async fn apply_action(
626        client_id: ClientId,
627        result: ClientActionResult,
628        dispatch: Dispatch,
629        app_state: &Arc<AppState>,
630        llm_client: &OllamaClient,
631        status_tx: &mpsc::UnboundedSender<String>,
632    ) -> Result<Applied> {
633        let (name, data) = match result {
634            ClientActionResult::Custom { name, data } => (name, data),
635            ClientActionResult::Disconnect => return Ok(Applied::Disconnect),
636            ClientActionResult::NoAction => return Ok(Applied::Executed("no_action".to_string())),
637            ClientActionResult::WaitForMore => {
638                return Ok(Applied::Executed("wait_for_more".to_string()))
639            }
640            ClientActionResult::SendData(_) => {
641                return Ok(Applied::Executed(
642                    "IPP owns no socket; raw send_data cannot be put on the wire".to_string(),
643                ))
644            }
645            ClientActionResult::Multiple(_) => {
646                return Ok(Applied::Executed(
647                    "IPP produces no Multiple results; nothing executed".to_string(),
648                ))
649            }
650        };
651
652        match name.as_str() {
653            "ipp_get_printer_attributes" => match dispatch {
654                Dispatch::Spawn => {
655                    Self::spawn_operation(
656                        client_id,
657                        app_state,
658                        Self::get_printer_attributes(
659                            client_id,
660                            app_state.clone(),
661                            llm_client.clone(),
662                            status_tx.clone(),
663                        ),
664                    )
665                    .await;
666                    Ok(Applied::Executed(
667                        "Get-Printer-Attributes dispatched".to_string(),
668                    ))
669                }
670                Dispatch::Await => {
671                    let exchange =
672                        Self::perform_get_printer_attributes(client_id, app_state, status_tx)
673                            .await?;
674                    let detail =
675                        format!("Get-Printer-Attributes completed -> {}", exchange.summary);
676                    Self::spawn_notify(client_id, app_state, exchange, llm_client, status_tx).await;
677                    Ok(Applied::Executed(detail))
678                }
679            },
680            "ipp_print_job" => {
681                let job_name = data
682                    .get("job_name")
683                    .and_then(|v| v.as_str())
684                    .unwrap_or("Untitled")
685                    .to_string();
686                let document_format = data
687                    .get("document_format")
688                    .and_then(|v| v.as_str())
689                    .map(|s| s.to_string());
690                let document_data = match data.get("document_data") {
691                    None => Vec::new(),
692                    Some(value) => crate::client::wire_values::bytes(
693                        value
694                            .as_array()
695                            .context("document_data must be a byte array")?,
696                    )?,
697                };
698
699                match dispatch {
700                    Dispatch::Spawn => {
701                        let log_name = job_name.clone();
702                        Self::spawn_operation(
703                            client_id,
704                            app_state,
705                            Self::print_job(
706                                client_id,
707                                job_name,
708                                document_format,
709                                document_data,
710                                app_state.clone(),
711                                llm_client.clone(),
712                                status_tx.clone(),
713                            ),
714                        )
715                        .await;
716                        Ok(Applied::Executed(format!(
717                            "Print-Job {log_name:?} dispatched"
718                        )))
719                    }
720                    Dispatch::Await => {
721                        let log_name = job_name.clone();
722                        let exchange = Self::perform_print_job(
723                            client_id,
724                            job_name,
725                            document_format,
726                            document_data,
727                            app_state,
728                            status_tx,
729                        )
730                        .await?;
731                        let detail =
732                            format!("Print-Job {log_name:?} completed -> {}", exchange.summary);
733                        Self::spawn_notify(client_id, app_state, exchange, llm_client, status_tx)
734                            .await;
735                        Ok(Applied::Executed(detail))
736                    }
737                }
738            }
739            "ipp_get_job_attributes" => {
740                let job_id = data.get("job_id").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
741                match dispatch {
742                    Dispatch::Spawn => {
743                        Self::spawn_operation(
744                            client_id,
745                            app_state,
746                            Self::get_job_attributes(
747                                client_id,
748                                job_id,
749                                app_state.clone(),
750                                llm_client.clone(),
751                                status_tx.clone(),
752                            ),
753                        )
754                        .await;
755                        Ok(Applied::Executed(format!(
756                            "Get-Job-Attributes for job {job_id} dispatched"
757                        )))
758                    }
759                    Dispatch::Await => {
760                        let exchange = Self::perform_get_job_attributes(
761                            client_id, job_id, app_state, status_tx,
762                        )
763                        .await?;
764                        let detail =
765                            format!("Get-Job-Attributes completed -> {}", exchange.summary);
766                        Self::spawn_notify(client_id, app_state, exchange, llm_client, status_tx)
767                            .await;
768                        Ok(Applied::Executed(detail))
769                    }
770                }
771            }
772            other => Ok(Applied::Executed(format!(
773                "unknown IPP custom result '{other}' was not executed"
774            ))),
775        }
776    }
777
778    /// Run a whole operation (network + response event) as a registered background task.
779    async fn spawn_operation(
780        client_id: ClientId,
781        app_state: &Arc<AppState>,
782        operation: impl std::future::Future<Output = Result<()>> + Send + 'static,
783    ) {
784        // Registered with AppState so stop_client can abort this task —
785        // dropping a JoinHandle only detaches it in Tokio.
786        let handle = tokio::spawn(async move {
787            // Small delay to ensure the printer is ready
788            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
789            if let Err(e) = operation.await {
790                error!("IPP client {} operation failed: {}", client_id, e);
791            }
792        });
793        app_state.register_client_task(client_id, handle).await;
794    }
795
796    /// Raise the response event from its own registered task, so the caller does not wait
797    /// on the LLM call (which a manual routing rule can park for the intercept timeout).
798    async fn spawn_notify(
799        client_id: ClientId,
800        app_state: &Arc<AppState>,
801        exchange: IppExchange,
802        llm_client: &OllamaClient,
803        status_tx: &mpsc::UnboundedSender<String>,
804    ) {
805        let state = app_state.clone();
806        let llm = llm_client.clone();
807        let tx = status_tx.clone();
808        let handle = tokio::spawn(async move {
809            Self::notify_response(client_id, exchange, state, llm, tx).await;
810        });
811        app_state.register_client_task(client_id, handle).await;
812    }
813
814    /// Serve injected commands until the client goes away.
815    async fn command_loop(
816        mut command_rx: mpsc::Receiver<ClientCommand>,
817        client_id: ClientId,
818        app_state: Arc<AppState>,
819        llm_client: OllamaClient,
820        status_tx: mpsc::UnboundedSender<String>,
821    ) {
822        use crate::llm::actions::protocol_trait::Protocol;
823        let protocol = crate::client::ipp::actions::IppClientProtocol::new();
824
825        while let Some(command) = command_rx.recv().await {
826            let action = command.action.clone();
827            let outcome = match protocol.execute_action(action.clone()) {
828                Err(e) => Ok(ClientSendOutcome::Rejected {
829                    error: e.to_string(),
830                }),
831                // Never `Sent`: the `ipp` crate owns the HTTP request and does not report
832                // how many bytes it serialised to, so a byte count here would be invented.
833                Ok(result) => match Self::apply_action(
834                    client_id,
835                    result,
836                    Dispatch::Await,
837                    &app_state,
838                    &llm_client,
839                    &status_tx,
840                )
841                .await
842                {
843                    Ok(Applied::Executed(detail)) => Ok(ClientSendOutcome::Executed { detail }),
844                    Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
845                    Err(e) => Err(e),
846                },
847            };
848
849            let outcome_json = match &outcome {
850                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
851                Err(e) => serde_json::json!({"error": e.to_string()}),
852            };
853            app_state
854                .record_access_log(
855                    AccessLogOwner::Client(client_id.as_u32()),
856                    protocol.protocol_name(),
857                    None,
858                    "injected_action",
859                    action,
860                    vec![outcome_json],
861                )
862                .await;
863
864            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
865            if let Err(e) = &outcome {
866                error!("IPP client {} injected action failed: {}", client_id, e);
867                let _ = status_tx.send(format!(
868                    "[WARN] Client {} injected action failed: {}",
869                    client_id, e
870                ));
871            }
872            let _ = status_tx.send("__UPDATE_UI__".to_string());
873            crate::client::command_support::reply(command, outcome);
874
875            if disconnect {
876                app_state
877                    .update_client_status(client_id, ClientStatus::Disconnected)
878                    .await;
879                break;
880            }
881        }
882
883        info!("IPP client {} command loop finished", client_id);
884        app_state.remove_client_handle(client_id).await;
885        let _ = status_tx.send("__UPDATE_UI__".to_string());
886    }
887}
888
889/// Does this absolute URL already carry a path beyond `/`?
890///
891/// Used to decide whether the `printer_path` startup parameter applies. An operator who wrote
892/// the whole URI out has already chosen the queue, and appending to it would produce
893/// `/printers/a/printers/b`.
894fn path_already_present(url: &str) -> bool {
895    // Skip the scheme, then look for a `/` that starts a non-empty path.
896    let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
897    match after_scheme.split_once('/') {
898        Some((_, path)) => !path.is_empty(),
899        None => false,
900    }
901}