Skip to main content

netget/client/maven/
mod.rs

1//! Maven client implementation
2pub mod actions;
3
4pub use actions::MavenClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::sync::mpsc;
10use tracing::{debug, error, info, warn};
11
12use crate::client::http_fetch::FetchClient;
13use crate::client::llm_budget::call_llm_for_client;
14use crate::client::maven::actions::MAVEN_CLIENT_CONNECTED_EVENT;
15use crate::llm::actions::client_trait::{Client, ClientActionResult};
16use crate::llm::ollama_client::OllamaClient;
17use crate::llm::ClientLlmResult;
18use crate::logging::emit::Log;
19use crate::protocol::Event;
20use crate::state::app_state::AppState;
21use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
22use crate::state::{AccessLogOwner, ClientId, ClientStatus};
23
24/// One completed Maven fetch: the payload its response event will carry, plus a summary
25/// of what the repository actually answered.
26///
27/// Split out of the operations below so the injected-command loop can await the HTTP
28/// round-trip - and report a truthful outcome - without also awaiting the LLM call the
29/// response event triggers.
30pub struct MavenFetch {
31    pub event_data: serde_json::Value,
32    pub summary: String,
33}
34
35/// What one executed action did.
36enum Applied {
37    /// The action ran; `detail` says what it did.
38    Executed(String),
39    /// The action asked to end the session.
40    Disconnect,
41}
42
43/// How a Maven operation is issued.
44#[derive(Clone, Copy)]
45enum Dispatch {
46    /// Spawn the whole operation and return immediately. Used by the connected-event
47    /// handler, which runs inline in `connect()` and must not block client creation on a
48    /// download that can take the full 30s timeout.
49    Spawn,
50    /// Await the HTTP exchange so the caller can report what actually happened, then raise
51    /// the response event from its own registered task. Used by the injected-command loop,
52    /// so a parked manual handler cannot wedge it for the length of a human's think time.
53    Await,
54}
55
56/// Cap a repository document before it becomes event data.
57///
58/// `pom_content` and `metadata_content` went into the event verbatim, hence into the
59/// LLM prompt verbatim. `maven-metadata.xml` for a busy artifact is routinely hundreds
60/// of kilobytes and a hostile repository can return arbitrarily more, so this was an
61/// unbounded prompt driven by a remote peer — and, before the depth bound below, an
62/// amplifier: every follow-up turn shipped another full document.
63///
64/// Char-boundary truncation via `truncate_for_log`, never `&s[..N]`.
65fn truncate_document(body: String) -> String {
66    const MAX_DOCUMENT_BYTES: usize = 16 * 1024;
67    if body.len() <= MAX_DOCUMENT_BYTES {
68        return body;
69    }
70    crate::utils::truncate_for_log(&body, MAX_DOCUMENT_BYTES)
71}
72
73/// The one `reqwest::Client` this protocol uses, built once.
74///
75/// Building a client is a **blocking** operation: it sets up the rustls stack and
76/// loads the platform root store, which on macOS reads the keychain through
77/// Security.framework, synchronously and serialised across processes. Every request
78/// here used to build a fresh one on the async runtime, which parks a tokio worker —
79/// the systemic defect `CLAUDE.md` records as having stalled a whole client runtime.
80///
81/// So: `OnceCell`, and `spawn_blocking` for the build itself.
82#[cfg(not(target_arch = "wasm32"))]
83static SHARED_HTTP_CLIENT: tokio::sync::OnceCell<reqwest::Client> =
84    tokio::sync::OnceCell::const_new();
85
86#[cfg(not(target_arch = "wasm32"))]
87async fn reqwest_client() -> Result<reqwest::Client> {
88    SHARED_HTTP_CLIENT
89        .get_or_try_init(|| async {
90            tokio::task::spawn_blocking(|| {
91                reqwest::Client::builder()
92                    .timeout(std::time::Duration::from_secs(30))
93                    .user_agent("NetGet-Maven/1.0")
94                    .build()
95            })
96            .await
97            .context("building the HTTP client panicked")?
98            .context("failed to build the HTTP client")
99        })
100        .await
101        .cloned()
102}
103
104/// The HTTP client every request here uses: the shared reqwest client natively
105/// ([`reqwest_client`]), and in the browser build the hyper HTTP/1.1 transport over the page's
106/// virtual loopback ([`crate::client::http_fetch`]) with the same timeout and User-Agent.
107async fn shared_http_client() -> Result<FetchClient> {
108    #[cfg(not(target_arch = "wasm32"))]
109    let client = FetchClient::from_reqwest(reqwest_client().await?);
110    #[cfg(target_arch = "wasm32")]
111    let client = FetchClient::transport(std::time::Duration::from_secs(30))
112        .with_user_agent("NetGet-Maven/1.0");
113    Ok(client)
114}
115
116/// How many model turns a single injected action may spawn before the chain is cut.
117///
118/// The Maven follow-up cycle is genuinely self-referential: `notify_maven` asks the
119/// model, `execute_maven_action` runs what it answered, `apply_action` awaits the HTTP
120/// exchange and then `spawn_notify` raises a response event — which is `notify_maven`
121/// again. Nothing counted the turns, so a model (or the static handler this protocol's
122/// own `get_startup_examples()` documents) that answers `maven_pom_received` with
123/// another `download_pom` looped forever, burning one HTTP request *and one LLM call*
124/// per turn, and registering two fresh tasks each time so the abort set grew without
125/// bound too.
126///
127/// npm and pypi bound this structurally — their `run_follow_ups` routes only through
128/// `perform_*` helpers that raise no event, so the chain is one deep. Maven's shape
129/// cannot do that (its whole point is that a follow-up *is* a new turn), so it takes
130/// the other answer CLAUDE.md gives: a depth bound, not silence. No boxing is needed
131/// because the cycle already passes through `tokio::spawn`.
132const MAX_FOLLOWUP_DEPTH: u8 = 6;
133
134/// Maven client that interacts with Maven repositories
135pub struct MavenClient;
136
137impl MavenClient {
138    /// Apply one already-parsed action against the live Maven client.
139    ///
140    /// The single place Maven actions become repository traffic, so an action injected
141    /// from the dashboard behaves exactly like one the model produced. Under
142    /// [`Dispatch::Await`] only the **network** half is awaited; the response event - and
143    /// the LLM call it makes - is raised from its own registered task afterwards, so a
144    /// `*` -> manual routing rule parking that event cannot wedge the command loop for the
145    /// length of a human's think time. The outcome stays truthful because it describes what
146    /// the repository actually answered, which is known before the event is raised.
147    async fn apply_action(
148        client_id: ClientId,
149        result: ClientActionResult,
150        dispatch: Dispatch,
151        depth: u8,
152        app_state: &Arc<AppState>,
153        llm_client: &OllamaClient,
154        status_tx: &mpsc::UnboundedSender<String>,
155    ) -> Result<Applied> {
156        let (name, data) = match result {
157            ClientActionResult::Custom { name, data } => (name, data),
158            ClientActionResult::Disconnect => return Ok(Applied::Disconnect),
159            ClientActionResult::NoAction => return Ok(Applied::Executed("no_action".to_string())),
160            ClientActionResult::WaitForMore => {
161                return Ok(Applied::Executed("wait_for_more".to_string()))
162            }
163            ClientActionResult::SendData(_) => {
164                return Ok(Applied::Executed(
165                    "Maven owns no socket; raw send_data cannot be put on the wire".to_string(),
166                ))
167            }
168            ClientActionResult::Multiple(_) => {
169                return Ok(Applied::Executed(
170                    "Maven produces no Multiple results; nothing executed".to_string(),
171                ))
172            }
173        };
174
175        let group_id = data["group_id"].as_str().unwrap_or_default().to_string();
176        let artifact_id = data["artifact_id"].as_str().unwrap_or_default().to_string();
177        let version = data["version"].as_str().unwrap_or_default().to_string();
178        let packaging = data["packaging"].as_str().map(|s| s.to_string());
179
180        match name.as_str() {
181            "maven_download_artifact" => {
182                let coords = format!("{group_id}:{artifact_id}:{version}");
183                match dispatch {
184                    Dispatch::Spawn => {
185                        Self::spawn_operation(
186                            client_id,
187                            app_state,
188                            Self::download_artifact(
189                                client_id,
190                                depth + 1,
191                                group_id,
192                                artifact_id,
193                                version,
194                                packaging,
195                                app_state.clone(),
196                                llm_client.clone(),
197                                status_tx.clone(),
198                            ),
199                        )
200                        .await;
201                        Ok(Applied::Executed(format!(
202                            "download_artifact {coords} dispatched"
203                        )))
204                    }
205                    Dispatch::Await => {
206                        let fetch = Self::perform_download_artifact(
207                            client_id,
208                            group_id,
209                            artifact_id,
210                            version,
211                            packaging,
212                            app_state,
213                            status_tx,
214                        )
215                        .await?;
216                        let detail = format!("download_artifact {coords} -> {}", fetch.summary);
217                        Self::spawn_notify(
218                            client_id,
219                            depth + 1,
220                            app_state,
221                            &crate::client::maven::actions::MAVEN_CLIENT_ARTIFACT_DOWNLOADED_EVENT,
222                            fetch.event_data,
223                            llm_client,
224                            status_tx,
225                        )
226                        .await;
227                        Ok(Applied::Executed(detail))
228                    }
229                }
230            }
231            "maven_download_pom" => {
232                let coords = format!("{group_id}:{artifact_id}:{version}");
233                match dispatch {
234                    Dispatch::Spawn => {
235                        Self::spawn_operation(
236                            client_id,
237                            app_state,
238                            Self::download_pom(
239                                client_id,
240                                depth + 1,
241                                group_id,
242                                artifact_id,
243                                version,
244                                app_state.clone(),
245                                llm_client.clone(),
246                                status_tx.clone(),
247                            ),
248                        )
249                        .await;
250                        Ok(Applied::Executed(format!(
251                            "download_pom {coords} dispatched"
252                        )))
253                    }
254                    Dispatch::Await => {
255                        let fetch = Self::perform_download_pom(
256                            client_id,
257                            group_id,
258                            artifact_id,
259                            version,
260                            app_state,
261                            status_tx,
262                        )
263                        .await?;
264                        let detail = format!("download_pom {coords} -> {}", fetch.summary);
265                        Self::spawn_notify(
266                            client_id,
267                            depth + 1,
268                            app_state,
269                            &crate::client::maven::actions::MAVEN_CLIENT_POM_RECEIVED_EVENT,
270                            fetch.event_data,
271                            llm_client,
272                            status_tx,
273                        )
274                        .await;
275                        Ok(Applied::Executed(detail))
276                    }
277                }
278            }
279            "maven_search_versions" => {
280                let coords = format!("{group_id}:{artifact_id}");
281                match dispatch {
282                    Dispatch::Spawn => {
283                        Self::spawn_operation(
284                            client_id,
285                            app_state,
286                            Self::search_versions(
287                                client_id,
288                                depth + 1,
289                                group_id,
290                                artifact_id,
291                                app_state.clone(),
292                                llm_client.clone(),
293                                status_tx.clone(),
294                            ),
295                        )
296                        .await;
297                        Ok(Applied::Executed(format!(
298                            "search_versions {coords} dispatched"
299                        )))
300                    }
301                    Dispatch::Await => {
302                        let fetch = Self::perform_search_versions(
303                            client_id,
304                            group_id,
305                            artifact_id,
306                            app_state,
307                            status_tx,
308                        )
309                        .await?;
310                        let detail = format!("search_versions {coords} -> {}", fetch.summary);
311                        Self::spawn_notify(
312                            client_id,
313                            depth + 1,
314                            app_state,
315                            &crate::client::maven::actions::MAVEN_CLIENT_METADATA_RECEIVED_EVENT,
316                            fetch.event_data,
317                            llm_client,
318                            status_tx,
319                        )
320                        .await;
321                        Ok(Applied::Executed(detail))
322                    }
323                }
324            }
325            other => {
326                warn!("Unknown Maven custom action: {}", other);
327                Ok(Applied::Executed(format!(
328                    "unknown Maven custom result '{other}' was not executed"
329                )))
330            }
331        }
332    }
333
334    /// Run a whole operation (network + response event) as a registered background task.
335    async fn spawn_operation(
336        client_id: ClientId,
337        app_state: &Arc<AppState>,
338        operation: impl std::future::Future<Output = Result<()>> + Send + 'static,
339    ) {
340        // Registered with AppState so stop_client can abort this task —
341        // dropping a JoinHandle only detaches it in Tokio.
342        let handle = tokio::spawn(async move {
343            if let Err(e) = operation.await {
344                error!("Maven client {} operation failed: {}", client_id, e);
345            }
346        });
347        app_state.register_client_task(client_id, handle).await;
348    }
349
350    /// Raise a response event from its own registered task, so the caller does not wait on
351    /// the LLM call (which a manual routing rule can park for the intercept timeout).
352    async fn spawn_notify(
353        client_id: ClientId,
354        depth: u8,
355        app_state: &Arc<AppState>,
356        event_type: &'static crate::protocol::EventType,
357        event_data: serde_json::Value,
358        llm_client: &OllamaClient,
359        status_tx: &mpsc::UnboundedSender<String>,
360    ) {
361        let state = app_state.clone();
362        let llm = llm_client.clone();
363        let tx = status_tx.clone();
364        let handle = tokio::spawn(async move {
365            Self::notify_maven(client_id, depth, event_type, event_data, state, llm, tx).await;
366        });
367        app_state.register_client_task(client_id, handle).await;
368    }
369
370    /// Raise one response event, hand it to the LLM and dispatch whatever the model
371    /// answers. The single LLM entry point for every Maven operation.
372    async fn notify_maven(
373        client_id: ClientId,
374        depth: u8,
375        event_type: &'static crate::protocol::EventType,
376        event_data: serde_json::Value,
377        app_state: Arc<AppState>,
378        llm_client: OllamaClient,
379        status_tx: mpsc::UnboundedSender<String>,
380    ) {
381        if depth > MAX_FOLLOWUP_DEPTH {
382            warn!(
383                "Maven client {} cut a follow-up chain at depth {} (limit {}): the model \
384                 kept answering each response event with another repository operation. \
385                 The chain is stopped, not the client.",
386                client_id, depth, MAX_FOLLOWUP_DEPTH
387            );
388            let _ = status_tx.send(format!(
389                "[WARN] Maven client {} follow-up chain cut at depth {}",
390                client_id, MAX_FOLLOWUP_DEPTH
391            ));
392            return;
393        }
394
395        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
396            return;
397        };
398
399        let protocol = Arc::new(crate::client::maven::actions::MavenClientProtocol::new());
400        let event = Event::new(event_type, event_data);
401
402        let memory = app_state
403            .get_memory_for_client(client_id)
404            .await
405            .unwrap_or_default();
406
407        match call_llm_for_client(
408            &llm_client,
409            &app_state,
410            client_id.to_string(),
411            &instruction,
412            &memory,
413            Some(&event),
414            protocol.as_ref(),
415            &status_tx,
416        )
417        .await
418        {
419            Ok(ClientLlmResult {
420                actions,
421                memory_updates,
422            }) => {
423                if let Some(mem) = memory_updates {
424                    app_state.set_memory_for_client(client_id, mem).await;
425                }
426
427                // Execute actions from LLM response
428                for action in actions {
429                    match protocol.as_ref().execute_action(action) {
430                        Ok(ClientActionResult::Custom { name, data }) => {
431                            Self::execute_maven_action(
432                                client_id,
433                                depth,
434                                name,
435                                data,
436                                app_state.clone(),
437                                llm_client.clone(),
438                                status_tx.clone(),
439                            );
440                        }
441                        Ok(ClientActionResult::Disconnect) => {
442                            // This arm used to log the disconnect and do nothing else,
443                            // so the log asserted something that had not happened.
444                            info!("Maven client {} disconnecting", client_id);
445                            app_state
446                                .update_client_status(client_id, ClientStatus::Disconnected)
447                                .await;
448                        }
449                        Ok(other) => {
450                            debug!("Maven client {} action result: {:?}", client_id, other);
451                        }
452                        Err(e) => {
453                            // `_ => {}` swallowed this: a model answering with an unknown
454                            // or malformed action produced no log at all.
455                            warn!(
456                                "Maven client {} could not execute the model's action: {}",
457                                client_id, e
458                            );
459                        }
460                    }
461                }
462            }
463            Err(e) => {
464                error!("LLM error for Maven client {}: {}", client_id, e);
465            }
466        }
467    }
468
469    /// Run [`Self::apply_action`] as a background task.
470    ///
471    /// Deliberately **synchronous**: it is called from [`Self::notify_maven`], which
472    /// `apply_action` itself schedules, and an `async fn` there would make the future type
473    /// infinitely recursive. Registering the handle is done from a second tiny task for the
474    /// same reason — `register_client_task` is async.
475    fn execute_maven_action(
476        client_id: ClientId,
477        depth: u8,
478        name: String,
479        data: serde_json::Value,
480        app_state: Arc<AppState>,
481        llm_client: OllamaClient,
482        status_tx: mpsc::UnboundedSender<String>,
483    ) {
484        let registrar = app_state.clone();
485        let handle = tokio::spawn(async move {
486            let result = ClientActionResult::Custom { name, data };
487            if let Err(e) = Self::apply_action(
488                client_id,
489                result,
490                Dispatch::Await,
491                depth,
492                &app_state,
493                &llm_client,
494                &status_tx,
495            )
496            .await
497            {
498                error!("Maven client {} action failed: {}", client_id, e);
499            }
500        });
501        // Registered with AppState so stop_client can abort this task —
502        // dropping a JoinHandle only detaches it in Tokio.
503        tokio::spawn(async move {
504            registrar.register_client_task(client_id, handle).await;
505        });
506    }
507
508    /// Serve injected commands until the client goes away.
509    async fn command_loop(
510        mut command_rx: mpsc::Receiver<ClientCommand>,
511        client_id: ClientId,
512        app_state: Arc<AppState>,
513        llm_client: OllamaClient,
514        status_tx: mpsc::UnboundedSender<String>,
515    ) {
516        use crate::llm::actions::protocol_trait::Protocol;
517        let protocol = crate::client::maven::actions::MavenClientProtocol::new();
518
519        while let Some(command) = command_rx.recv().await {
520            let action = command.action.clone();
521            let outcome = match protocol.execute_action(action.clone()) {
522                Err(e) => Ok(ClientSendOutcome::Rejected {
523                    error: e.to_string(),
524                }),
525                // Never `Sent`: reqwest owns the socket and does not report how many bytes
526                // the request serialised to, so a byte count here would be invented.
527                Ok(result) => match Self::apply_action(
528                    client_id,
529                    result,
530                    Dispatch::Await,
531                    0,
532                    &app_state,
533                    &llm_client,
534                    &status_tx,
535                )
536                .await
537                {
538                    Ok(Applied::Executed(detail)) => Ok(ClientSendOutcome::Executed { detail }),
539                    Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
540                    Err(e) => Err(e),
541                },
542            };
543
544            let outcome_json = match &outcome {
545                Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
546                Err(e) => serde_json::json!({"error": e.to_string()}),
547            };
548            app_state
549                .record_access_log(
550                    AccessLogOwner::Client(client_id.as_u32()),
551                    protocol.protocol_name(),
552                    None,
553                    "injected_action",
554                    action,
555                    vec![outcome_json],
556                )
557                .await;
558
559            let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
560            if let Err(e) = &outcome {
561                error!("Maven client {} injected action failed: {}", client_id, e);
562                let _ = status_tx.send(format!(
563                    "[WARN] Client {} injected action failed: {}",
564                    client_id, e
565                ));
566            }
567            let _ = status_tx.send("__UPDATE_UI__".to_string());
568            crate::client::command_support::reply(command, outcome);
569
570            if disconnect {
571                app_state
572                    .update_client_status(client_id, ClientStatus::Disconnected)
573                    .await;
574                break;
575            }
576        }
577
578        info!("Maven client {} command loop finished", client_id);
579        app_state.remove_client_handle(client_id).await;
580        let _ = status_tx.send("__UPDATE_UI__".to_string());
581    }
582
583    /// Connect to a Maven repository with integrated LLM actions
584    pub async fn connect_with_llm_actions(
585        repository_url: String,
586        _llm_client: OllamaClient,
587        app_state: Arc<AppState>,
588        status_tx: mpsc::UnboundedSender<String>,
589        client_id: ClientId,
590    ) -> Result<SocketAddr> {
591        // For Maven, "connection" is logical - we interact with HTTP-based repositories
592        // Default to Maven Central if no specific URL provided
593        let repo_url = if repository_url.is_empty()
594            || repository_url == "maven"
595            || repository_url == "maven-central"
596        {
597            "https://repo.maven.apache.org/maven2".to_string()
598        } else if repository_url.contains("://") {
599            repository_url
600        } else {
601            // A bare `host:port` (what the dashboard's `[ + Maven client ]` and a model asked to
602            // "connect to 127.0.0.1:8080" produce) gets a scheme: every URL built from it is
603            // absolute, and without one reqwest refused each request as a relative URL.
604            // `https://` natively, like npm and pypi; `http://` in the browser build, whose
605            // transport speaks nothing else and whose repositories are servers on the page's
606            // virtual network.
607            let scheme = if cfg!(target_arch = "wasm32") {
608                "http"
609            } else {
610                "https"
611            };
612            format!("{scheme}://{}", repository_url.trim_end_matches('/'))
613        };
614        // In the browser build the transport has no TLS: an `https://` repository — Maven
615        // Central included — is refused here, with the reason, rather than on the first request.
616        crate::client::http_fetch::check_url(&repo_url)?;
617
618        info!(
619            "Maven client {} initialized for repository: {}",
620            client_id, repo_url
621        );
622
623        // Store client in protocol_data
624        app_state
625            .with_client_mut(client_id, |client| {
626                client.set_protocol_field(
627                    "http_client".to_string(),
628                    serde_json::json!("initialized"),
629                );
630                client.set_protocol_field(
631                    "repository_url".to_string(),
632                    serde_json::json!(repo_url.clone()),
633                );
634            })
635            .await;
636
637        // Update status
638        app_state
639            .update_client_status(client_id, ClientStatus::Connected)
640            .await;
641        Log::new(Some(&status_tx)).info(format!(
642            "Maven client {} connected to repository: {}",
643            client_id, repo_url
644        ));
645        let _ = status_tx.send("__UPDATE_UI__".to_string());
646
647        // Command channel for injected actions (the dashboard's [ send ] row).
648        // Registered and *served* before the maven_connected LLM call below: a
649        // dashboard-created client defaults to a `*` -> manual routing rule, so that
650        // call can park for minutes and [ send ] has to work for the whole park.
651        let command_rx =
652            crate::client::command_support::register_command_channel(&app_state, client_id).await;
653
654        // The command loop replaces the old 5s "is the client gone yet?" poll: when the
655        // client is removed its handle is dropped, the channel closes and `recv()`
656        // returns None, so the loop notices removal immediately instead of up to 5s later.
657        // Registered with AppState so stop_client can abort it —
658        // dropping a JoinHandle only detaches it in Tokio.
659        let command_task = tokio::spawn(Self::command_loop(
660            command_rx,
661            client_id,
662            app_state.clone(),
663            _llm_client.clone(),
664            status_tx.clone(),
665        ));
666        app_state
667            .register_client_task(client_id, command_task)
668            .await;
669
670        // Call LLM with connected event
671        if let Some(instruction) = app_state.get_instruction_for_client(client_id).await {
672            let protocol = Arc::new(crate::client::maven::actions::MavenClientProtocol::new());
673            let event = Event::new(
674                &MAVEN_CLIENT_CONNECTED_EVENT,
675                serde_json::json!({
676                    "repository_url": repo_url,
677                }),
678            );
679
680            let memory = app_state
681                .get_memory_for_client(client_id)
682                .await
683                .unwrap_or_default();
684
685            match call_llm_for_client(
686                &_llm_client,
687                &app_state,
688                client_id.to_string(),
689                &instruction,
690                &memory,
691                Some(&event),
692                protocol.as_ref(),
693                &status_tx,
694            )
695            .await
696            {
697                Ok(ClientLlmResult {
698                    actions,
699                    memory_updates,
700                }) => {
701                    // Update memory
702                    if let Some(mem) = memory_updates {
703                        app_state.set_memory_for_client(client_id, mem).await;
704                    }
705
706                    // Execute actions through the same path injected commands use.
707                    // Dispatch::Spawn so connect() is not blocked on a download.
708                    for action in actions {
709                        match protocol.as_ref().execute_action(action) {
710                            Ok(result) => match Self::apply_action(
711                                client_id,
712                                result,
713                                Dispatch::Spawn,
714                                0,
715                                &app_state,
716                                &_llm_client,
717                                &status_tx,
718                            )
719                            .await
720                            {
721                                Ok(Applied::Disconnect) => {
722                                    info!("Maven client {} disconnecting", client_id);
723                                }
724                                Ok(Applied::Executed(detail)) => {
725                                    info!("Maven client {} after connect: {}", client_id, detail);
726                                }
727                                Err(e) => {
728                                    error!("Maven client {} action failed: {}", client_id, e);
729                                }
730                            },
731                            Err(e) => {
732                                error!("Maven client {} rejected action: {}", client_id, e);
733                            }
734                        }
735                    }
736                }
737                Err(e) => {
738                    error!("LLM error for Maven client {}: {}", client_id, e);
739                }
740            }
741        }
742
743        // Return a dummy local address (Maven is HTTP-based)
744        Ok("0.0.0.0:0".parse().unwrap())
745    }
746
747    /// Construct Maven artifact URL
748    ///
749    /// Converts Maven coordinates (groupId:artifactId:version) into repository URL
750    /// Example: org.apache.commons:commons-lang3:3.12.0 ->
751    ///   https://repo.maven.apache.org/maven2/org/apache/commons/commons-lang3/3.12.0/commons-lang3-3.12.0.jar
752    pub fn artifact_url(
753        repository_url: &str,
754        group_id: &str,
755        artifact_id: &str,
756        version: &str,
757        packaging: &str,
758    ) -> String {
759        let group_path = group_id.replace('.', "/");
760        format!(
761            "{}/{}/{}/{}/{}-{}.{}",
762            repository_url.trim_end_matches('/'),
763            group_path,
764            artifact_id,
765            version,
766            artifact_id,
767            version,
768            packaging
769        )
770    }
771
772    /// Construct POM URL
773    pub fn pom_url(
774        repository_url: &str,
775        group_id: &str,
776        artifact_id: &str,
777        version: &str,
778    ) -> String {
779        Self::artifact_url(repository_url, group_id, artifact_id, version, "pom")
780    }
781
782    /// Construct metadata URL
783    pub fn metadata_url(repository_url: &str, group_id: &str, artifact_id: &str) -> String {
784        let group_path = group_id.replace('.', "/");
785        format!(
786            "{}/{}/{}/maven-metadata.xml",
787            repository_url.trim_end_matches('/'),
788            group_path,
789            artifact_id
790        )
791    }
792
793    /// Download artifact from Maven repository and hand the result to the LLM.
794    #[allow(clippy::too_many_arguments)]
795    pub async fn download_artifact(
796        client_id: ClientId,
797        depth: u8,
798        group_id: String,
799        artifact_id: String,
800        version: String,
801        packaging: Option<String>,
802        app_state: Arc<AppState>,
803        llm_client: OllamaClient,
804        status_tx: mpsc::UnboundedSender<String>,
805    ) -> Result<()> {
806        let fetch = Self::perform_download_artifact(
807            client_id,
808            group_id,
809            artifact_id,
810            version,
811            packaging,
812            &app_state,
813            &status_tx,
814        )
815        .await?;
816        Self::notify_maven(
817            client_id,
818            depth,
819            &crate::client::maven::actions::MAVEN_CLIENT_ARTIFACT_DOWNLOADED_EVENT,
820            fetch.event_data,
821            app_state,
822            llm_client,
823            status_tx,
824        )
825        .await;
826        Ok(())
827    }
828
829    /// Download the artifact only. No LLM involvement, so a caller can await this and know
830    /// exactly what the repository answered.
831    pub async fn perform_download_artifact(
832        client_id: ClientId,
833        group_id: String,
834        artifact_id: String,
835        version: String,
836        packaging: Option<String>,
837        app_state: &AppState,
838        status_tx: &mpsc::UnboundedSender<String>,
839    ) -> Result<MavenFetch> {
840        let packaging = packaging.unwrap_or_else(|| "jar".to_string());
841        let repository_url = Self::repository_url(app_state, client_id).await?;
842
843        let artifact_url = Self::artifact_url(
844            &repository_url,
845            &group_id,
846            &artifact_id,
847            &version,
848            &packaging,
849        );
850
851        Log::new(Some(status_tx)).info(format!(
852            "Maven client {} downloading artifact {}:{}:{} from {}",
853            client_id, group_id, artifact_id, version, artifact_url
854        ));
855
856        match Self::http_client().await?.get(&artifact_url).send().await {
857            Ok(response) => {
858                let status = response.status();
859                let status_code = status.as_u16();
860
861                if status.is_success() {
862                    let content_length = response.content_length().unwrap_or(0);
863                    // `unwrap_or_default()` here turned a reset or a timeout
864                    // mid-body into `""`, and the caller then reported
865                    // "HTTP 200 (0 bytes)" and raised the response event with an empty
866                    // artifact. The model reasoned about a repository answer that never
867                    // arrived, and nothing was logged. Propagate instead: every caller
868                    // already handles `Err`.
869                    let body_bytes = response
870                        .bytes()
871                        .await
872                        .context("reading the artifact body")?;
873
874                    info!(
875                        "Maven client {} artifact downloaded: {} bytes",
876                        client_id,
877                        body_bytes.len()
878                    );
879
880                    Ok(MavenFetch {
881                        summary: format!("HTTP {} ({} bytes)", status_code, body_bytes.len()),
882                        event_data: serde_json::json!({
883                            "group_id": group_id,
884                            "artifact_id": artifact_id,
885                            "version": version,
886                            "packaging": packaging,
887                            "url": artifact_url,
888                            "size_bytes": body_bytes.len(),
889                            "content_length": content_length,
890                        }),
891                    })
892                } else {
893                    let error_msg = format!("Artifact not found: HTTP {}", status_code);
894                    Log::new(Some(status_tx))
895                        .error(format!("Maven client {} error: {}", client_id, error_msg));
896                    Err(anyhow::anyhow!(error_msg))
897                }
898            }
899            Err(e) => {
900                Log::new(Some(status_tx))
901                    .error(format!("Maven client {} download failed: {}", client_id, e));
902                Err(e)
903            }
904        }
905    }
906
907    /// Download and parse POM file, then hand it to the LLM.
908    pub async fn download_pom(
909        client_id: ClientId,
910        depth: u8,
911        group_id: String,
912        artifact_id: String,
913        version: String,
914        app_state: Arc<AppState>,
915        llm_client: OllamaClient,
916        status_tx: mpsc::UnboundedSender<String>,
917    ) -> Result<()> {
918        let fetch = Self::perform_download_pom(
919            client_id,
920            group_id,
921            artifact_id,
922            version,
923            &app_state,
924            &status_tx,
925        )
926        .await?;
927        Self::notify_maven(
928            client_id,
929            depth,
930            &crate::client::maven::actions::MAVEN_CLIENT_POM_RECEIVED_EVENT,
931            fetch.event_data,
932            app_state,
933            llm_client,
934            status_tx,
935        )
936        .await;
937        Ok(())
938    }
939
940    /// Download the POM only. No LLM involvement.
941    pub async fn perform_download_pom(
942        client_id: ClientId,
943        group_id: String,
944        artifact_id: String,
945        version: String,
946        app_state: &AppState,
947        status_tx: &mpsc::UnboundedSender<String>,
948    ) -> Result<MavenFetch> {
949        let repository_url = Self::repository_url(app_state, client_id).await?;
950        let pom_url = Self::pom_url(&repository_url, &group_id, &artifact_id, &version);
951
952        Log::new(Some(status_tx)).info(format!(
953            "Maven client {} downloading POM {}:{}:{} from {}",
954            client_id, group_id, artifact_id, version, pom_url
955        ));
956
957        match Self::http_client().await?.get(&pom_url).send().await {
958            Ok(response) => {
959                let status = response.status();
960                let status_code = status.as_u16();
961
962                if status.is_success() {
963                    // See the note in `perform_download_artifact`: a failed body read
964                    // must not be reported as an empty-but-successful POM.
965                    let pom_content =
966                        truncate_document(response.text().await.context("reading the POM body")?);
967
968                    info!(
969                        "Maven client {} POM downloaded: {} bytes",
970                        client_id,
971                        pom_content.len()
972                    );
973
974                    Ok(MavenFetch {
975                        summary: format!("HTTP {} ({} bytes)", status_code, pom_content.len()),
976                        event_data: serde_json::json!({
977                            "group_id": group_id,
978                            "artifact_id": artifact_id,
979                            "version": version,
980                            "url": pom_url,
981                            "pom_content": pom_content,
982                        }),
983                    })
984                } else {
985                    let error_msg = format!("POM not found: HTTP {}", status_code);
986                    Log::new(Some(status_tx))
987                        .error(format!("Maven client {} error: {}", client_id, error_msg));
988                    Err(anyhow::anyhow!(error_msg))
989                }
990            }
991            Err(e) => {
992                Log::new(Some(status_tx)).error(format!(
993                    "Maven client {} POM download failed: {}",
994                    client_id, e
995                ));
996                Err(e)
997            }
998        }
999    }
1000
1001    /// Search for artifact versions (via maven-metadata.xml) and hand them to the LLM.
1002    pub async fn search_versions(
1003        client_id: ClientId,
1004        depth: u8,
1005        group_id: String,
1006        artifact_id: String,
1007        app_state: Arc<AppState>,
1008        llm_client: OllamaClient,
1009        status_tx: mpsc::UnboundedSender<String>,
1010    ) -> Result<()> {
1011        let fetch =
1012            Self::perform_search_versions(client_id, group_id, artifact_id, &app_state, &status_tx)
1013                .await?;
1014        Self::notify_maven(
1015            client_id,
1016            depth,
1017            &crate::client::maven::actions::MAVEN_CLIENT_METADATA_RECEIVED_EVENT,
1018            fetch.event_data,
1019            app_state,
1020            llm_client,
1021            status_tx,
1022        )
1023        .await;
1024        Ok(())
1025    }
1026
1027    /// Fetch maven-metadata.xml only. No LLM involvement.
1028    pub async fn perform_search_versions(
1029        client_id: ClientId,
1030        group_id: String,
1031        artifact_id: String,
1032        app_state: &AppState,
1033        status_tx: &mpsc::UnboundedSender<String>,
1034    ) -> Result<MavenFetch> {
1035        let repository_url = Self::repository_url(app_state, client_id).await?;
1036        let metadata_url = Self::metadata_url(&repository_url, &group_id, &artifact_id);
1037
1038        Log::new(Some(status_tx)).info(format!(
1039            "Maven client {} searching versions {}:{} via {}",
1040            client_id, group_id, artifact_id, metadata_url
1041        ));
1042
1043        match Self::http_client().await?.get(&metadata_url).send().await {
1044            Ok(response) => {
1045                let status = response.status();
1046                let status_code = status.as_u16();
1047
1048                if status.is_success() {
1049                    // See the note in `perform_download_artifact`: a failed body read
1050                    // must not be reported as empty-but-successful metadata.
1051                    let metadata_content = truncate_document(
1052                        response
1053                            .text()
1054                            .await
1055                            .context("reading the maven-metadata.xml body")?,
1056                    );
1057
1058                    info!(
1059                        "Maven client {} metadata received: {} bytes",
1060                        client_id,
1061                        metadata_content.len()
1062                    );
1063
1064                    Ok(MavenFetch {
1065                        summary: format!(
1066                            "HTTP {} ({} bytes of maven-metadata.xml)",
1067                            status_code,
1068                            metadata_content.len()
1069                        ),
1070                        event_data: serde_json::json!({
1071                            "group_id": group_id,
1072                            "artifact_id": artifact_id,
1073                            "url": metadata_url,
1074                            "metadata_content": metadata_content,
1075                        }),
1076                    })
1077                } else {
1078                    let error_msg = format!("Metadata not found: HTTP {}", status_code);
1079                    Log::new(Some(status_tx))
1080                        .error(format!("Maven client {} error: {}", client_id, error_msg));
1081                    Err(anyhow::anyhow!(error_msg))
1082                }
1083            }
1084            Err(e) => {
1085                Log::new(Some(status_tx)).error(format!(
1086                    "Maven client {} metadata fetch failed: {}",
1087                    client_id, e
1088                ));
1089                Err(e)
1090            }
1091        }
1092    }
1093
1094    /// The client's configured repository URL.
1095    async fn repository_url(app_state: &AppState, client_id: ClientId) -> Result<String> {
1096        app_state
1097            .with_client_mut(client_id, |client| {
1098                client
1099                    .get_protocol_field("repository_url")
1100                    .and_then(|v| v.as_str())
1101                    .map(|s| s.to_string())
1102            })
1103            .await
1104            .flatten()
1105            .context("No repository URL found")
1106    }
1107
1108    /// The shared client, not a fresh one per request.
1109    ///
1110    /// This used to build a `reqwest::Client` on every call — a blocking rustls +
1111    /// platform-root-store setup on the async runtime. See `shared_http_client`.
1112    async fn http_client() -> Result<FetchClient> {
1113        shared_http_client().await
1114    }
1115}