1pub mod actions;
3
4pub use actions::NpmClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::sync::mpsc;
10use tracing::{error, info, warn};
11
12use crate::client::http_fetch::FetchClient;
13use crate::client::llm_budget::call_llm_for_client;
14use crate::client::npm::actions::{
15 NPM_CLIENT_PACKAGE_INFO_RECEIVED_EVENT, NPM_CLIENT_SEARCH_RESULTS_RECEIVED_EVENT,
16};
17use crate::llm::actions::client_trait::{Client, ClientActionResult};
18use crate::llm::ollama_client::OllamaClient;
19use crate::llm::ClientLlmResult;
20use crate::logging::emit::Log;
21use crate::protocol::Event;
22use crate::state::app_state::AppState;
23use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
24use crate::state::{AccessLogOwner, ClientId, ClientStatus};
25
26pub struct NpmPackageInfo {
32 pub package_name: String,
33 pub version: String,
34 pub description: String,
35 pub versions: Vec<String>,
36 pub dist: Option<serde_json::Value>,
37}
38
39pub struct NpmSearchResults {
41 pub query: String,
42 pub results: Vec<serde_json::Value>,
43 pub total: u64,
44}
45
46enum Applied {
48 Executed(String),
50 Disconnect,
52}
53
54#[cfg(not(target_arch = "wasm32"))]
64static SHARED_HTTP_CLIENT: tokio::sync::OnceCell<reqwest::Client> =
65 tokio::sync::OnceCell::const_new();
66
67#[cfg(not(target_arch = "wasm32"))]
68async fn reqwest_client() -> Result<reqwest::Client> {
69 SHARED_HTTP_CLIENT
70 .get_or_try_init(|| async {
71 tokio::task::spawn_blocking(|| {
72 reqwest::Client::builder()
73 .timeout(std::time::Duration::from_secs(120))
74 .user_agent("NetGet NPM Client/1.0")
75 .build()
76 })
77 .await
78 .context("building the HTTP client panicked")?
79 .context("failed to build the HTTP client")
80 })
81 .await
82 .cloned()
83}
84
85async fn shared_http_client() -> Result<FetchClient> {
89 #[cfg(not(target_arch = "wasm32"))]
90 let client = FetchClient::from_reqwest(reqwest_client().await?);
91 #[cfg(target_arch = "wasm32")]
92 let client = FetchClient::transport(std::time::Duration::from_secs(120))
93 .with_user_agent("NetGet NPM Client/1.0")
94 .with_max_body(MAX_TARBALL_BYTES as usize);
95 Ok(client)
96}
97
98const MAX_TARBALL_BYTES: u64 = 64 * 1024 * 1024;
104
105fn resolve_registry_url(remote_addr: &str) -> Result<String> {
125 let trimmed = remote_addr.trim().trim_end_matches('/');
126 if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
127 return Ok(trimmed.to_string());
128 }
129 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("npm") {
132 anyhow::bail!(
133 "NPM client needs a registry in remote_addr (for example \
134 http://127.0.0.1:4873 for a local Verdaccio, or name the public registry \
135 explicitly if that is what you want); refusing to reach the public npm registry \
136 by omission"
137 );
138 }
139 let scheme = if cfg!(target_arch = "wasm32") {
140 "http"
141 } else {
142 "https"
143 };
144 Ok(format!("{scheme}://{trimmed}"))
145}
146
147pub struct NpmClient;
149
150impl NpmClient {
151 pub async fn connect_with_llm_actions(
153 remote_addr: String,
154 llm_client: OllamaClient,
155 app_state: Arc<AppState>,
156 status_tx: mpsc::UnboundedSender<String>,
157 client_id: ClientId,
158 ) -> Result<SocketAddr> {
159 let registry_url = resolve_registry_url(&remote_addr)?;
168 crate::client::http_fetch::check_url(®istry_url)?;
171
172 info!("NPM client {} initialized for {}", client_id, registry_url);
173
174 app_state
180 .with_client_mut(client_id, |client| {
181 client
182 .set_protocol_field("npm_client".to_string(), serde_json::json!("initialized"));
183 client.set_protocol_field(
184 "registry_url".to_string(),
185 serde_json::json!(registry_url),
186 );
187 })
188 .await;
189
190 app_state
192 .update_client_status(client_id, ClientStatus::Connected)
193 .await;
194 Log::new(Some(&status_tx)).info(format!(
195 "NPM client {} ready for {}",
196 client_id, registry_url
197 ));
198 let _ = status_tx.send("__UPDATE_UI__".to_string());
199
200 let command_rx =
205 crate::client::command_support::register_command_channel(&app_state, client_id).await;
206
207 let task_registrar = app_state.clone();
213 let connected_llm_client = llm_client.clone();
214 let connected_status_tx = status_tx.clone();
215 let task_handle = tokio::spawn(Self::command_loop(
216 command_rx, client_id, app_state, llm_client, status_tx,
217 ));
218 task_registrar
219 .register_client_task(client_id, task_handle)
220 .await;
221
222 let connected_state = task_registrar.clone();
233 let connected_llm = connected_llm_client;
234 let connected_status = connected_status_tx;
235 let connected = tokio::spawn(async move {
236 let Some(instruction) = connected_state.get_instruction_for_client(client_id).await
237 else {
238 return;
239 };
240 let protocol = crate::client::npm::actions::NpmClientProtocol::new();
241 let event = Event::new(
242 &crate::client::npm::actions::NPM_CLIENT_CONNECTED_EVENT,
243 serde_json::json!({ "registry_url": registry_url.clone(), }),
244 );
245 match crate::client::llm_budget::call_llm_for_client(
246 &connected_llm,
247 &connected_state,
248 client_id.to_string(),
249 &instruction,
250 "",
251 Some(&event),
252 &protocol,
253 &connected_status,
254 )
255 .await
256 {
257 Ok(result) => {
258 if let Some(mem) = result.memory_updates {
259 connected_state.set_memory_for_client(client_id, mem).await;
260 }
261 Self::run_follow_ups(
262 client_id,
263 result.actions,
264 &connected_state,
265 &connected_status,
266 )
267 .await;
268 }
269 Err(e) => error!(
270 "NPM client {} LLM error on connected event: {}",
271 client_id, e
272 ),
273 }
274 });
275 task_registrar
276 .register_client_task(client_id, connected)
277 .await;
278
279 Ok("0.0.0.0:0".parse().unwrap())
281 }
282
283 pub async fn get_package_info(
285 client_id: ClientId,
286 package_name: String,
287 version: String,
288 app_state: Arc<AppState>,
289 llm_client: OllamaClient,
290 status_tx: mpsc::UnboundedSender<String>,
291 ) -> Result<()> {
292 let info = Self::perform_get_package_info(
293 client_id,
294 package_name,
295 version,
296 &app_state,
297 &status_tx,
298 )
299 .await?;
300 Self::notify_package_info(client_id, info, app_state, llm_client, status_tx).await;
301 Ok(())
302 }
303
304 pub async fn perform_get_package_info(
307 client_id: ClientId,
308 package_name: String,
309 version: String,
310 app_state: &AppState,
311 status_tx: &mpsc::UnboundedSender<String>,
312 ) -> Result<NpmPackageInfo> {
313 let registry_url = app_state
315 .with_client_mut(client_id, |client| {
316 client
317 .get_protocol_field("registry_url")
318 .and_then(|v| v.as_str())
319 .map(|s| s.to_string())
320 })
321 .await
322 .flatten()
323 .context("No registry URL found")?;
324
325 let encoded_name = package_name.replace("/", "%2f");
327
328 let url = if version == "latest" {
329 format!("{}/{}", registry_url, encoded_name)
330 } else {
331 format!("{}/{}/{}", registry_url, encoded_name, version)
332 };
333
334 info!(
335 "NPM client {} getting package info: {} ({})",
336 client_id, package_name, version
337 );
338
339 let http_client = shared_http_client().await?;
341
342 match http_client.get(&url).send().await {
344 Ok(response) => {
345 let status = response.status();
346
347 if !status.is_success() {
348 let error_text = response
349 .text()
350 .await
351 .unwrap_or_else(|_| "Unknown error".to_string());
352 Log::new(Some(status_tx)).error(format!(
353 "NPM client {} failed to get package {}: {} - {}",
354 client_id, package_name, status, error_text
355 ));
356 return Err(anyhow::anyhow!("NPM request failed: {}", status));
357 }
358
359 let package_data: serde_json::Value = response
361 .json()
362 .await
363 .context("Failed to parse NPM response")?;
364
365 info!(
366 "NPM client {} received package info for {}",
367 client_id, package_name
368 );
369
370 let description = package_data
372 .get("description")
373 .and_then(|v| v.as_str())
374 .unwrap_or("")
375 .to_string();
376
377 let dist_tags = package_data.get("dist-tags");
378 let latest_version = dist_tags
379 .and_then(|dt| dt.get("latest"))
380 .and_then(|v| v.as_str())
381 .unwrap_or("unknown")
382 .to_string();
383
384 let versions = package_data
385 .get("versions")
386 .and_then(|v| v.as_object())
387 .map(|obj| obj.keys().cloned().collect::<Vec<_>>())
388 .unwrap_or_default();
389
390 let dist = if version == "latest" {
391 package_data
392 .get("dist-tags")
393 .and_then(|dt| dt.get("latest"))
394 .and_then(|lv| {
395 package_data
396 .get("versions")
397 .and_then(|vs| vs.get(lv.as_str().unwrap_or("")))
398 })
399 .and_then(|v| v.get("dist"))
400 .cloned()
401 } else {
402 package_data.get("dist").cloned()
403 };
404
405 let resolved_version = if version == "latest" {
406 latest_version
407 } else {
408 version
409 };
410
411 Ok(NpmPackageInfo {
412 package_name,
413 version: resolved_version,
414 description,
415 versions,
416 dist,
417 })
418 }
419 Err(e) => {
420 Log::new(Some(status_tx))
421 .error(format!("NPM client {} request failed: {}", client_id, e));
422 Err(e)
423 }
424 }
425 }
426
427 async fn run_follow_ups(
440 client_id: ClientId,
441 actions: Vec<serde_json::Value>,
442 app_state: &Arc<AppState>,
443 status_tx: &mpsc::UnboundedSender<String>,
444 ) {
445 use crate::llm::actions::client_trait::{Client, ClientActionResult};
446 let protocol = crate::client::npm::actions::NpmClientProtocol::new();
447 for action in actions {
448 let (name, data) = match protocol.execute_action(action.clone()) {
453 Ok(ClientActionResult::Custom { name, data }) => (name, data),
454 Ok(ClientActionResult::Disconnect) => {
455 info!(
456 "NPM client {} disconnecting on the model's answer",
457 client_id
458 );
459 app_state
460 .update_client_status(client_id, ClientStatus::Disconnected)
461 .await;
462 return;
463 }
464 Ok(_) => continue,
465 Err(e) => {
466 warn!(
467 "NPM client {} could not execute the model's follow-up action: {}",
468 client_id, e
469 );
470 continue;
471 }
472 };
473 let outcome: Result<()> = match name.as_str() {
474 "npm_get_package" => Self::perform_get_package_info(
475 client_id,
476 data["package_name"]
477 .as_str()
478 .unwrap_or_default()
479 .to_string(),
480 data["version"].as_str().unwrap_or("latest").to_string(),
481 app_state,
482 status_tx,
483 )
484 .await
485 .map(|_| ()),
486 "npm_search" => Self::perform_search_packages(
487 client_id,
488 data["query"].as_str().unwrap_or_default().to_string(),
489 data["limit"].as_u64().unwrap_or(10),
490 app_state,
491 status_tx,
492 )
493 .await
494 .map(|_| ()),
495 "npm_download_tarball" => Self::download_tarball(
499 client_id,
500 data["package_name"]
501 .as_str()
502 .unwrap_or_default()
503 .to_string(),
504 data["version"].as_str().unwrap_or("latest").to_string(),
505 app_state.clone(),
506 status_tx.clone(),
507 )
508 .await
509 .map(|_| ()),
510 other => {
511 info!(
512 "NPM client {} follow-up '{}' has no non-notifying path; skipped",
513 client_id, other
514 );
515 Ok(())
516 }
517 };
518 if let Err(e) = outcome {
519 error!("NPM client {} follow-up action failed: {}", client_id, e);
520 }
521 }
522 }
523
524 async fn notify_package_info(
525 client_id: ClientId,
526 info: NpmPackageInfo,
527 app_state: Arc<AppState>,
528 llm_client: OllamaClient,
529 status_tx: mpsc::UnboundedSender<String>,
530 ) {
531 let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
532 return;
533 };
534
535 let protocol = Arc::new(crate::client::npm::actions::NpmClientProtocol::new());
536 let event = Event::new(
537 &NPM_CLIENT_PACKAGE_INFO_RECEIVED_EVENT,
538 serde_json::json!({
539 "package_name": info.package_name,
540 "version": info.version,
541 "description": info.description,
542 "versions": info.versions,
543 "dist": info.dist,
544 }),
545 );
546
547 let memory = app_state
548 .get_memory_for_client(client_id)
549 .await
550 .unwrap_or_default();
551
552 match call_llm_for_client(
553 &llm_client,
554 &app_state,
555 client_id.to_string(),
556 &instruction,
557 &memory,
558 Some(&event),
559 protocol.as_ref(),
560 &status_tx,
561 )
562 .await
563 {
564 Ok(ClientLlmResult {
565 actions,
566 memory_updates,
567 }) => {
568 if let Some(mem) = memory_updates {
570 app_state.set_memory_for_client(client_id, mem).await;
571 }
572 Self::run_follow_ups(client_id, actions, &app_state, &status_tx).await;
573 }
574 Err(e) => {
575 error!("LLM error for NPM client {}: {}", client_id, e);
576 }
577 }
578 }
579
580 pub async fn search_packages(
582 client_id: ClientId,
583 query: String,
584 limit: u64,
585 app_state: Arc<AppState>,
586 llm_client: OllamaClient,
587 status_tx: mpsc::UnboundedSender<String>,
588 ) -> Result<()> {
589 let results =
590 Self::perform_search_packages(client_id, query, limit, &app_state, &status_tx).await?;
591 Self::notify_search_results(client_id, results, app_state, llm_client, status_tx).await;
592 Ok(())
593 }
594
595 pub async fn perform_search_packages(
598 client_id: ClientId,
599 query: String,
600 limit: u64,
601 app_state: &AppState,
602 status_tx: &mpsc::UnboundedSender<String>,
603 ) -> Result<NpmSearchResults> {
604 let registry_url = app_state
610 .with_client_mut(client_id, |client| {
611 client
612 .get_protocol_field("registry_url")
613 .and_then(|v| v.as_str())
614 .map(|s| s.to_string())
615 })
616 .await
617 .flatten()
618 .context("No registry URL found")?;
619 let search_url = format!("{}/-/v1/search", registry_url.trim_end_matches('/'));
620
621 info!(
622 "NPM client {} searching for: {} (limit: {})",
623 client_id, query, limit
624 );
625
626 let http_client = shared_http_client().await?;
628
629 let url = format!(
631 "{}?text={}&size={}",
632 search_url,
633 urlencoding::encode(&query),
634 limit
635 );
636
637 match http_client.get(&url).send().await {
639 Ok(response) => {
640 let status = response.status();
641
642 if !status.is_success() {
643 let error_text = response
644 .text()
645 .await
646 .unwrap_or_else(|_| "Unknown error".to_string());
647 Log::new(Some(status_tx)).error(format!(
648 "NPM client {} search failed: {} - {}",
649 client_id, status, error_text
650 ));
651 return Err(anyhow::anyhow!("NPM search failed: {}", status));
652 }
653
654 let search_data: serde_json::Value = response
656 .json()
657 .await
658 .context("Failed to parse NPM search response")?;
659
660 let results = search_data.get("objects")
661 .and_then(|v| v.as_array())
662 .map(|arr| {
663 arr.iter().map(|obj| {
664 let package = obj.get("package");
665 serde_json::json!({
666 "name": package.and_then(|p| p.get("name")).and_then(|v| v.as_str()).unwrap_or(""),
667 "version": package.and_then(|p| p.get("version")).and_then(|v| v.as_str()).unwrap_or(""),
668 "description": package.and_then(|p| p.get("description")).and_then(|v| v.as_str()).unwrap_or(""),
669 })
670 }).collect::<Vec<_>>()
671 })
672 .unwrap_or_default();
673
674 let total = search_data
675 .get("total")
676 .and_then(|v| v.as_u64())
677 .unwrap_or(results.len() as u64);
678
679 info!(
680 "NPM client {} received {} search results",
681 client_id,
682 results.len()
683 );
684
685 Ok(NpmSearchResults {
686 query,
687 results,
688 total,
689 })
690 }
691 Err(e) => {
692 Log::new(Some(status_tx))
693 .error(format!("NPM client {} search failed: {}", client_id, e));
694 Err(e)
695 }
696 }
697 }
698
699 async fn notify_search_results(
701 client_id: ClientId,
702 results: NpmSearchResults,
703 app_state: Arc<AppState>,
704 llm_client: OllamaClient,
705 status_tx: mpsc::UnboundedSender<String>,
706 ) {
707 let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
708 return;
709 };
710
711 let protocol = Arc::new(crate::client::npm::actions::NpmClientProtocol::new());
712 let event = Event::new(
713 &NPM_CLIENT_SEARCH_RESULTS_RECEIVED_EVENT,
714 serde_json::json!({
715 "query": results.query,
716 "results": results.results,
717 "total": results.total,
718 }),
719 );
720
721 let memory = app_state
722 .get_memory_for_client(client_id)
723 .await
724 .unwrap_or_default();
725
726 match call_llm_for_client(
727 &llm_client,
728 &app_state,
729 client_id.to_string(),
730 &instruction,
731 &memory,
732 Some(&event),
733 protocol.as_ref(),
734 &status_tx,
735 )
736 .await
737 {
738 Ok(ClientLlmResult {
739 actions,
740 memory_updates,
741 }) => {
742 if let Some(mem) = memory_updates {
744 app_state.set_memory_for_client(client_id, mem).await;
745 }
746 Self::run_follow_ups(client_id, actions, &app_state, &status_tx).await;
747 }
748 Err(e) => {
749 error!("LLM error for NPM client {}: {}", client_id, e);
750 }
751 }
752 }
753
754 pub async fn download_tarball(
768 client_id: ClientId,
769 package_name: String,
770 version: String,
771 app_state: Arc<AppState>,
772 status_tx: mpsc::UnboundedSender<String>,
773 ) -> Result<String> {
774 let registry_url = app_state
776 .with_client_mut(client_id, |client| {
777 client
778 .get_protocol_field("registry_url")
779 .and_then(|v| v.as_str())
780 .map(|s| s.to_string())
781 })
782 .await
783 .flatten()
784 .context("No registry URL found")?;
785
786 let encoded_name = package_name.replace("/", "%2f");
787 let info_url = format!("{}/{}", registry_url, encoded_name);
788
789 info!(
790 "NPM client {} downloading tarball for {} ({})",
791 client_id, package_name, version
792 );
793
794 let http_client = shared_http_client().await?;
795
796 let package_data: serde_json::Value = http_client
798 .get(&info_url)
799 .send()
800 .await?
801 .json()
802 .await
803 .context("Failed to get package info")?;
804
805 let dist = if version == "latest" {
807 package_data
808 .get("dist-tags")
809 .and_then(|dt| dt.get("latest"))
810 .and_then(|lv| {
811 package_data
812 .get("versions")
813 .and_then(|vs| vs.get(lv.as_str().unwrap_or("")))
814 })
815 .and_then(|v| v.get("dist"))
816 } else {
817 package_data
818 .get("versions")
819 .and_then(|vs| vs.get(&version))
820 .and_then(|v| v.get("dist"))
821 }
822 .context("Could not find a dist block for the requested version")?
823 .clone();
824
825 let tarball_url = dist
826 .get("tarball")
827 .and_then(|t| t.as_str())
828 .context("Could not find tarball URL")?
829 .to_string();
830
831 let advertised = dist
834 .get("integrity")
835 .or_else(|| dist.get("shasum"))
836 .and_then(|v| v.as_str())
837 .unwrap_or("(none advertised)")
838 .to_string();
839
840 info!("NPM client {} downloading from: {}", client_id, tarball_url);
841
842 let mut response = http_client.get(&tarball_url).send().await?;
847 if let Some(len) = response.content_length() {
848 if len > MAX_TARBALL_BYTES {
849 anyhow::bail!(
850 "{} advertises {} bytes, over the {} byte limit; refusing to download it",
851 tarball_url,
852 len,
853 MAX_TARBALL_BYTES
854 );
855 }
856 }
857 let mut received: u64 = 0;
858 while let Some(chunk) = response.chunk().await? {
859 received += chunk.len() as u64;
860 if received > MAX_TARBALL_BYTES {
861 anyhow::bail!(
862 "{} exceeded the {} byte limit mid-transfer; aborting",
863 tarball_url,
864 MAX_TARBALL_BYTES
865 );
866 }
867 }
868
869 let summary = format!(
870 "{}@{}: {} bytes from {} (registry integrity: {}); not saved - NetGet stores nothing",
871 package_name, version, received, tarball_url, advertised
872 );
873 Log::new(Some(&status_tx)).info(format!(
874 "NPM client {} fetched tarball {}",
875 client_id, summary
876 ));
877
878 Ok(summary)
879 }
880
881 async fn apply_action(
892 client_id: ClientId,
893 result: ClientActionResult,
894 app_state: &Arc<AppState>,
895 llm_client: &OllamaClient,
896 status_tx: &mpsc::UnboundedSender<String>,
897 ) -> Result<Applied> {
898 match result {
899 ClientActionResult::Custom { name, data } => match name.as_str() {
900 "npm_get_package" => {
901 let package_name = data["package_name"]
902 .as_str()
903 .unwrap_or_default()
904 .to_string();
905 let version = data["version"].as_str().unwrap_or("latest").to_string();
906 let requested = format!("{package_name}@{version}");
907
908 let info = Self::perform_get_package_info(
909 client_id,
910 package_name,
911 version,
912 app_state,
913 status_tx,
914 )
915 .await?;
916 let detail = format!(
917 "get_package_info {requested} -> version {}, {} published version(s)",
918 info.version,
919 info.versions.len()
920 );
921 Self::spawn_notify(
922 client_id,
923 app_state,
924 Self::notify_package_info(
925 client_id,
926 info,
927 app_state.clone(),
928 llm_client.clone(),
929 status_tx.clone(),
930 ),
931 )
932 .await;
933 Ok(Applied::Executed(detail))
934 }
935 "npm_search" => {
936 let query = data["query"].as_str().unwrap_or_default().to_string();
937 let limit = data["limit"].as_u64().unwrap_or(20);
938 let requested = query.clone();
939
940 let results = Self::perform_search_packages(
941 client_id, query, limit, app_state, status_tx,
942 )
943 .await?;
944 let detail = format!(
945 "search_packages {requested:?} -> {} result(s) of {} total",
946 results.results.len(),
947 results.total
948 );
949 Self::spawn_notify(
950 client_id,
951 app_state,
952 Self::notify_search_results(
953 client_id,
954 results,
955 app_state.clone(),
956 llm_client.clone(),
957 status_tx.clone(),
958 ),
959 )
960 .await;
961 Ok(Applied::Executed(detail))
962 }
963 "npm_download_tarball" => {
964 let package_name = data["package_name"]
967 .as_str()
968 .unwrap_or_default()
969 .to_string();
970 let version = data["version"].as_str().unwrap_or("latest").to_string();
971 let summary = Self::download_tarball(
972 client_id,
973 package_name,
974 version,
975 app_state.clone(),
976 status_tx.clone(),
977 )
978 .await?;
979 Ok(Applied::Executed(format!("download_tarball {summary}")))
980 }
981 other => Ok(Applied::Executed(format!(
982 "unknown NPM custom result '{other}' was not executed"
983 ))),
984 },
985 ClientActionResult::Disconnect => Ok(Applied::Disconnect),
986 ClientActionResult::NoAction => Ok(Applied::Executed("no_action".to_string())),
987 ClientActionResult::WaitForMore => Ok(Applied::Executed("wait_for_more".to_string())),
988 ClientActionResult::SendData(_) => Ok(Applied::Executed(
989 "NPM owns no socket; raw send_data cannot be put on the wire".to_string(),
990 )),
991 ClientActionResult::Multiple(_) => Ok(Applied::Executed(
992 "NPM produces no Multiple results; nothing executed".to_string(),
993 )),
994 }
995 }
996
997 async fn spawn_notify(
999 client_id: ClientId,
1000 app_state: &Arc<AppState>,
1001 notify: impl std::future::Future<Output = ()> + Send + 'static,
1002 ) {
1003 let handle = tokio::spawn(notify);
1004 app_state.register_client_task(client_id, handle).await;
1005 }
1006
1007 async fn command_loop(
1009 mut command_rx: mpsc::Receiver<ClientCommand>,
1010 client_id: ClientId,
1011 app_state: Arc<AppState>,
1012 llm_client: OllamaClient,
1013 status_tx: mpsc::UnboundedSender<String>,
1014 ) {
1015 use crate::llm::actions::protocol_trait::Protocol;
1016 let protocol = crate::client::npm::actions::NpmClientProtocol::new();
1017
1018 while let Some(command) = command_rx.recv().await {
1019 let action = command.action.clone();
1020 let outcome = match protocol.execute_action(action.clone()) {
1021 Err(e) => Ok(ClientSendOutcome::Rejected {
1022 error: e.to_string(),
1023 }),
1024 Ok(result) => {
1029 match Self::apply_action(client_id, result, &app_state, &llm_client, &status_tx)
1030 .await
1031 {
1032 Ok(Applied::Executed(detail)) => Ok(ClientSendOutcome::Executed { detail }),
1033 Ok(Applied::Disconnect) => Ok(ClientSendOutcome::Disconnected),
1034 Err(e) => Err(e),
1035 }
1036 }
1037 };
1038
1039 let outcome_json = match &outcome {
1040 Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
1041 Err(e) => serde_json::json!({"error": e.to_string()}),
1042 };
1043 app_state
1044 .record_access_log(
1045 AccessLogOwner::Client(client_id.as_u32()),
1046 protocol.protocol_name(),
1047 None,
1048 "injected_action",
1049 action,
1050 vec![outcome_json],
1051 )
1052 .await;
1053
1054 let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
1055 if let Err(e) = &outcome {
1056 error!("NPM client {} injected action failed: {}", client_id, e);
1057 let _ = status_tx.send(format!(
1058 "[WARN] Client {} injected action failed: {}",
1059 client_id, e
1060 ));
1061 }
1062 let _ = status_tx.send("__UPDATE_UI__".to_string());
1063 crate::client::command_support::reply(command, outcome);
1064
1065 if disconnect {
1066 app_state
1067 .update_client_status(client_id, ClientStatus::Disconnected)
1068 .await;
1069 break;
1070 }
1071 }
1072
1073 info!("NPM client {} command loop finished", client_id);
1074 app_state.remove_client_handle(client_id).await;
1075 let _ = status_tx.send("__UPDATE_UI__".to_string());
1076 }
1077}