netget/server/pypi/mod.rs
1//! PyPI (Python Package Index) server implementation
2//!
3//! Implements PEP 503 Simple Repository API for serving Python packages.
4
5pub mod actions;
6
7use std::collections::HashMap;
8use std::convert::Infallible;
9use std::net::SocketAddr;
10use std::sync::Arc;
11
12use bytes::Bytes;
13use http_body_util::{BodyExt, Full};
14use hyper::body::Incoming;
15use hyper::server::conn::http1;
16use hyper::service::service_fn;
17use hyper::{Request, Response};
18use hyper_util::rt::TokioIo;
19use tokio::sync::mpsc;
20use tracing::trace;
21
22use crate::llm::action_helper::call_llm;
23use crate::llm::ollama_client::OllamaClient;
24use crate::llm::ActionResult;
25use crate::logging::emit::Log;
26use crate::protocol::Event;
27use crate::server::accept_bounded::{
28 accept_bounded, watch_idle, ConnectionActivity, ConnectionLimiter,
29};
30use crate::server::connection::ConnectionId;
31use crate::server::PypiProtocol;
32use crate::state::app_state::AppState;
33use actions::PYPI_REQUEST_EVENT;
34
35/// How long a peer that has produced nothing at all may hold this connection.
36///
37/// HTTP is client-speaks-first: the request line is the first thing on the wire and every
38/// installer sends it inside its dial path, so a peer that has connected and sent no byte
39/// has begun no request. Thirty seconds sits between nginx's `client_header_timeout` default of
40/// 60s and Apache's `RequestReadTimeout header=20`, both of which bound the same thing.
41///
42/// Enforced with `TcpStream::peek` **before** hyper sees the socket. hyper owns every read once
43/// `serve_connection` starts, and a deadline on its reads would be wrong here rather than merely
44/// awkward: hyper keeps polling the connection for new frames while a request is being answered,
45/// so such a deadline would fire in the middle of an LLM round-trip. `peek` waits for data
46/// without consuming it, so the request line is still there for hyper afterwards.
47const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
48
49/// How long an established connection may sit **silent** between requests.
50///
51/// This bounds the silence and never a transfer. A request being answered holds
52/// `ConnectionActivity` busy for the whole of it - the model round-trip included, and a `manual`
53/// rule parking the event for a human at the dashboard (`src/state/intercepts.rs`, 300s by
54/// default) - and `watch_idle` reports a busy connection as not idle at all, so the clock only
55/// ever runs on a connection with nothing in flight.
56///
57/// Five minutes: twenty times pip's own socket timeout (`--timeout`, 15 seconds by default), so
58/// a pip still waiting on us has long since given up before this fires, and four times nginx's
59/// `keepalive_timeout` default of 75s. What is left inside the window is the gap while pip
60/// resolves the next requirement or installs the wheel it just fetched - local work, on a
61/// connection it may reuse afterwards. RFC 9112 requires every client to retry a request on a
62/// persistent connection the server closed, and pip's urllib3 pool does exactly that.
63const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
64
65/// Concurrent connections this server admits.
66///
67/// Each admitted connection may hold one whole in-memory response body - a simple-index page or
68/// a wheel - so the cap is what turns that per-connection bound into a total one. pip fetches
69/// essentially serially, so 256 is far above any real installer and far below what socket
70/// exhaustion needs.
71const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
72
73/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
74///
75/// `503 Service Unavailable` with `Retry-After`, in PyPI's own vocabulary:
76/// pip treats a 503 as retryable and backs off, and a human
77/// with `curl` reads it directly.
78/// Nothing of netget's is interpolated into it - the peer gets a category and the log gets the
79/// reason, under `decision=fail_closed_connection_cap`.
80const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
81 Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
82
83/// PyPI server that delegates package serving to LLM
84pub struct PypiServer;
85
86impl PypiServer {
87 /// Spawn the PyPI server with integrated LLM actions
88 pub async fn spawn_with_llm_actions(
89 listen_addr: SocketAddr,
90 llm_client: OllamaClient,
91 app_state: Arc<AppState>,
92 status_tx: mpsc::UnboundedSender<String>,
93 server_id: crate::state::ServerId,
94 ) -> anyhow::Result<SocketAddr> {
95 let listener =
96 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
97 let local_addr = listener.local_addr()?;
98 Log::new(Some(&status_tx)).info(format!("PyPI server listening on {}", local_addr));
99
100 let protocol = Arc::new(PypiProtocol::new());
101
102 // Spawn server loop
103 let task_registrar = app_state.clone();
104 let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
105 let accept_handle = tokio::spawn(async move {
106 loop {
107 match accept_bounded(
108 &listener,
109 &limiter,
110 CONNECTION_CAP_REFUSAL,
111 "PYPI",
112 Some(&status_tx),
113 )
114 .await
115 {
116 Ok((stream, remote_addr, permit)) => {
117 let connection_id =
118 ConnectionId::new(app_state.get_next_unified_id().await);
119 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
120 Log::new(Some(&status_tx)).info(format!(
121 "Accepted PyPI connection {} from {}",
122 connection_id, remote_addr
123 ));
124
125 // Add connection to ServerInstance
126 use crate::state::server::{
127 ConnectionState as ServerConnectionState, ConnectionStatus,
128 ProtocolConnectionInfo,
129 };
130 let now = crate::utils::clock::Instant::now();
131 let conn_state = ServerConnectionState {
132 id: connection_id,
133 remote_addr,
134 local_addr: local_addr_conn,
135 bytes_sent: 0,
136 bytes_received: 0,
137 packets_sent: 0,
138 packets_received: 0,
139 last_activity: now,
140 status: ConnectionStatus::Active,
141 status_changed_at: now,
142 protocol_info: ProtocolConnectionInfo::empty(),
143 };
144 app_state
145 .add_connection_to_server(server_id, conn_state)
146 .await;
147 let _ = status_tx.send("__UPDATE_UI__".to_string());
148
149 let llm_client_clone = llm_client.clone();
150 let app_state_clone = app_state.clone();
151 let status_tx_clone = status_tx.clone();
152 let protocol_clone = protocol.clone();
153
154 // Spawn a task to handle this connection
155 // Tracked, not detached: stop_server must abort this task too.
156 let task_owner = app_state.clone();
157 task_owner
158 .spawn_server_task(server_id, async move {
159 // Held for the life of the connection, so the
160 // cap counts live clients, not accepts.
161 let _permit = permit;
162
163 // The first-byte bound, before hyper sees the
164 // socket. `peek` waits for data without
165 // consuming it, so the request line is still
166 // there for hyper afterwards, and it bounds
167 // exactly the case that needs bounding: a peer
168 // that connected and said nothing at all.
169 let spoke = matches!(
170 tokio::time::timeout(
171 FIRST_BYTE_READ_TIMEOUT,
172 stream.peek(&mut [0u8; 1]),
173 )
174 .await,
175 Ok(Ok(n)) if n > 0
176 );
177
178 if spoke {
179 let io = TokioIo::new(stream);
180
181 // Clone for service closure
182 let status_for_service = status_tx_clone.clone();
183 let app_state_for_service = app_state_clone.clone();
184
185 // Whether this connection is answering
186 // anything. The watchdog below reads it, so
187 // only genuine silence - never work in
188 // flight - can close a connection.
189 let activity =
190 Arc::new(ConnectionActivity::new());
191 let activity_for_service = Arc::clone(&activity);
192 // Create a service that handles requests with LLM
193 let service = service_fn(move |req: Request<Incoming>| {
194 let llm_clone = llm_client_clone.clone();
195 let state_clone = app_state_for_service.clone();
196 let status_clone = status_for_service.clone();
197 let protocol_clone = protocol_clone.clone();
198 // Busy for the whole of this request - the
199 // model round-trip included, and a `manual`
200 // rule parked for a human - so the idle
201 // watchdog can never close the connection an
202 // answer belongs to.
203 let activity = Arc::clone(&activity_for_service);
204 async move {
205 let _busy = activity.busy();
206 handle_pypi_request_with_llm_actions(
207 req,
208 connection_id,
209 server_id,
210 llm_clone,
211 state_clone,
212 status_clone,
213 protocol_clone,
214 )
215 .await
216 }
217 });
218
219 // Serve HTTP/1 on this connection
220 let conn = http1::Builder::new().serve_connection(io, service);
221 tokio::pin!(conn);
222 tokio::select! {
223 result = &mut conn => {
224 if let Err(err) = result {
225 Log::new(Some(&status_tx_clone))
226 .error(format!("Error serving PyPI connection: {:?}", err));
227 }
228 }
229 // The idle bound, over `ConnectionActivity`
230 // rather than over a read: hyper owns every
231 // read once `serve_connection` starts, and
232 // keeps polling for frames while a request is
233 // being answered, so a deadline on those reads
234 // would fire mid-answer. A connection with work
235 // in flight is not idle at all.
236 _ = watch_idle(
237 Arc::clone(&activity),
238 IDLE_BETWEEN_REQUESTS_TIMEOUT,
239 ) => {
240 Log::new(Some(&status_tx_clone)).debug(format!(
241 "PyPI connection {} idle for {}s; closing",
242 connection_id,
243 IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
244 ));
245 }
246 }
247 } else {
248 Log::new(Some(&status_tx_clone)).debug(format!(
249 "PyPI peer {} sent no request within {}s; closing \
250 decision=fail_closed_first_byte_timeout",
251 remote_addr,
252 FIRST_BYTE_READ_TIMEOUT.as_secs()
253 ));
254 }
255
256 // Mark connection as closed
257 app_state_clone
258 .close_connection_on_server(server_id, connection_id)
259 .await;
260 Log::new(Some(&status_tx_clone))
261 .info(format!("PyPI connection {connection_id} closed"));
262 let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
263 })
264 .await;
265 }
266 Err(e) => {
267 Log::new(Some(&status_tx))
268 .error(format!("Failed to accept PyPI connection: {}", e));
269 break;
270 }
271 }
272 }
273 });
274
275 // Register the accept loop so stop_server can abort it and release the port.
276 task_registrar
277 .register_server_task(server_id, accept_handle)
278 .await;
279
280 Ok(local_addr)
281 }
282}
283
284/// Build the 502 sent when model or handler output cannot be turned into a
285/// valid HTTP response (bad status code, malformed header name, no answer at all).
286///
287/// The peer gets a category, never a diagnosis: what exactly was wrong with the action
288/// output is netget's own business and goes to the log only.
289/// See `crate::utils::wire_failure`.
290fn bad_gateway() -> Response<Full<Bytes>> {
291 Response::builder()
292 .status(502)
293 .header("Content-Type", "text/plain; charset=utf-8")
294 .body(Full::new(Bytes::from(
295 crate::utils::WireFailure::Unavailable.prefixed_text(),
296 )))
297 .expect("502 response with a literal body is always valid")
298}
299
300/// The 413 for a request body over [`MAX_REQUEST_BODY_BYTES`].
301///
302/// Static text: the peer learns the fact and the limit, never why the read failed.
303fn payload_too_large() -> Response<Full<Bytes>> {
304 Response::builder()
305 .status(413)
306 .header("Content-Type", "text/plain; charset=utf-8")
307 .body(Full::new(Bytes::from(format!(
308 "Payload Too Large: request bodies are limited to {} bytes\n",
309 crate::server::http_common::handler::MAX_REQUEST_BODY_BYTES
310 ))))
311 .expect("413 response with a literal body is always valid")
312}
313
314/// Build the reply for an LLM/backend failure.
315///
316/// The two categories get distinct HTTP codes on purpose: pip retries a 503 with
317/// `Retry-After` and records a 500 as a hard failure, so collapsing them would make a
318/// transient overload look permanent. The body is a static category string — the error
319/// itself is logged, never written to the socket.
320fn llm_failure_response(failure: crate::utils::WireFailure) -> Response<Full<Bytes>> {
321 let mut builder = Response::builder()
322 .header("Content-Type", "text/plain; charset=utf-8")
323 .status(if failure.is_overloaded() { 503 } else { 500 });
324 if failure.is_overloaded() {
325 builder = builder.header("Retry-After", "5");
326 }
327 builder
328 .body(Full::new(Bytes::from(failure.prefixed_text())))
329 .expect("failure response with a literal body is always valid")
330}
331
332/// Approximate the bytes this request cost on the wire.
333///
334/// hyper hands us a parsed `Request`, so the original head is gone; this
335/// reconstructs its size from the parts that survive. It is an estimate, and
336/// deliberately so — the alternative is a `↓` counter frozen at 0, which reads as
337/// "this peer sent nothing" rather than "we did not measure".
338fn approximate_request_bytes(req: &Request<Incoming>) -> u64 {
339 use hyper::body::Body;
340 let head: usize = req.method().as_str().len()
341 + req.uri().to_string().len()
342 + 12 // " HTTP/1.1\r\n" plus the blank line terminating the head
343 + req
344 .headers()
345 .iter()
346 .map(|(name, value)| name.as_str().len() + value.len() + 4)
347 .sum::<usize>();
348 head as u64 + req.body().size_hint().lower()
349}
350
351/// Record one request/response exchange against the connection's counters, then
352/// return the response unchanged.
353///
354/// `update_connection_stats` is what the dashboard rail's `↓ ↑` columns and the
355/// connection-scoped task prompts read, and it is what keeps `last_activity`
356/// moving. Without it a busy PyPI server draws every peer as idle having sent
357/// and received nothing.
358#[allow(clippy::too_many_arguments)]
359async fn handle_pypi_request_with_llm_actions(
360 req: Request<Incoming>,
361 connection_id: ConnectionId,
362 server_id: crate::state::ServerId,
363 llm_client: OllamaClient,
364 app_state: Arc<AppState>,
365 status_tx: mpsc::UnboundedSender<String>,
366 protocol: Arc<PypiProtocol>,
367) -> Result<Response<Full<Bytes>>, Infallible> {
368 let bytes_received = approximate_request_bytes(&req);
369 let response = handle_pypi_request_with_llm_actions_inner(
370 req,
371 connection_id,
372 server_id,
373 llm_client,
374 app_state.clone(),
375 status_tx,
376 protocol,
377 )
378 .await;
379
380 let bytes_sent = response
381 .as_ref()
382 .ok()
383 .and_then(|resp| {
384 use hyper::body::Body;
385 resp.body().size_hint().exact()
386 })
387 .unwrap_or(0);
388 app_state
389 .update_connection_stats(
390 server_id,
391 connection_id,
392 Some(bytes_received),
393 Some(bytes_sent),
394 Some(1),
395 Some(1),
396 )
397 .await;
398
399 response
400}
401
402/// Handle a single PyPI request with integrated LLM actions
403async fn handle_pypi_request_with_llm_actions_inner(
404 req: Request<Incoming>,
405 connection_id: ConnectionId,
406 server_id: crate::state::ServerId,
407 llm_client: OllamaClient,
408 app_state: Arc<AppState>,
409 status_tx: mpsc::UnboundedSender<String>,
410 protocol: Arc<PypiProtocol>,
411) -> Result<Response<Full<Bytes>>, Infallible> {
412 // TRACE: Handler invoked (FileOnly)
413 Log::new(Some(&status_tx)).trace(format!(
414 "🔍 PyPI handler called for connection {}",
415 connection_id
416 ));
417
418 // Extract request details first for logging
419 let method = req.method().to_string();
420 let uri = req.uri().to_string();
421 let path = req.uri().path().to_string();
422
423 // Extract headers
424 let mut headers = HashMap::new();
425 for (name, value) in req.headers() {
426 if let Ok(value_str) = value.to_str() {
427 headers.insert(name.to_string(), value_str.to_string());
428 }
429 }
430
431 // Read the body, bounded. `Incoming` has no default limit, and this body is
432 // buffered whole *and* interpolated into the LLM prompt below, so an
433 // unauthenticated POST of arbitrary length would allocate twice over — the
434 // unbounded-upload shape. `Limited` errors as soon as the cap is passed rather
435 // than after buffering, so an oversized request costs at most the cap.
436 let body_bytes = match http_body_util::Limited::new(
437 req.into_body(),
438 crate::server::http_common::handler::MAX_REQUEST_BODY_BYTES,
439 )
440 .collect()
441 .await
442 {
443 Ok(collected) => collected.to_bytes(),
444 Err(e) => {
445 // Do NOT fall through to an empty body: a truncated request handed to the
446 // model looks like a complete one, and the model would answer a request it
447 // never saw. 413 is the honest reply, and it costs no LLM call.
448 Log::new(Some(&status_tx)).warn(format!(
449 "PyPI {} {} decision=refused_body_too_large (limit {} bytes) \u{2192} 413: {}",
450 method,
451 uri,
452 crate::server::http_common::handler::MAX_REQUEST_BODY_BYTES,
453 e
454 ));
455 return Ok(payload_too_large());
456 }
457 };
458
459 // Determine request type based on path
460 // PEP 503 project URLs end with '/', but pip and poetry both follow
461 // redirects, so a client may also ask for /simple/<name> without one.
462 let request_type = if path == "/" || path == "/simple" || path == "/simple/" {
463 "list_packages"
464 } else if path.starts_with("/simple/") {
465 "list_files"
466 } else if path.starts_with("/packages/") {
467 "download_file"
468 } else {
469 "unknown"
470 };
471
472 // Extract package name if applicable
473 let package_name = if request_type == "list_files" {
474 path.trim_start_matches("/simple/")
475 .trim_end_matches('/')
476 .to_string()
477 } else if request_type == "download_file" {
478 path.trim_start_matches("/packages/")
479 .split('/')
480 .last()
481 .unwrap_or("")
482 .to_string()
483 } else {
484 String::new()
485 };
486
487 // DEBUG: Log request summary (FileOnly)
488 Log::new(Some(&status_tx)).debug(format!(
489 "PyPI request: {} {} [{}] ({} bytes) from {:?}",
490 method,
491 uri,
492 request_type,
493 body_bytes.len(),
494 connection_id
495 ));
496
497 // TRACE: Log full request details
498 trace!("PyPI request headers:");
499 for (name, value) in &headers {
500 trace!(" {}: {}", name, value);
501 }
502
503 // Create PyPI request event (FileOnly)
504 Log::new(Some(&status_tx)).trace(format!(
505 "🔍 Creating PyPI event: path={}, request_type={}",
506 path, request_type
507 ));
508
509 let body_text = String::from_utf8_lossy(&body_bytes);
510 let event = Event::new(
511 &PYPI_REQUEST_EVENT,
512 serde_json::json!({
513 "method": method,
514 "uri": uri,
515 "path": path,
516 "headers": headers,
517 "body": body_text,
518 "request_type": request_type,
519 "package_name": package_name,
520 }),
521 );
522
523 Log::new(Some(&status_tx)).trace("🔍 Calling LLM for PyPI request");
524
525 // Call LLM to generate PyPI response
526 match call_llm(
527 &llm_client,
528 &app_state,
529 server_id,
530 Some(connection_id),
531 &event,
532 protocol.as_ref(),
533 )
534 .await
535 {
536 Ok(execution_result) => {
537 Log::new(Some(&status_tx)).debug(format!(
538 "LLM PyPI response received, {} protocol results",
539 execution_result.protocol_results.len()
540 ));
541
542 // Display messages
543 for msg in execution_result.messages {
544 let _ = status_tx.send(msg);
545 }
546
547 // Extract the PyPI response from the protocol results.
548 //
549 // There is deliberately no default here. This used to pre-set
550 // `status_code = 200` with an empty body, so a model that answered with no
551 // action at all — or a handler configured with `actions: []` — produced a
552 // `200 OK` with zero bytes. To pip that reads as "the project exists and has
553 // no distributions", i.e. silence became a successful answer. A no-answer is
554 // a failure and must look like one on the wire.
555 let mut status_code: Option<u16> = None;
556 let mut response_headers = HashMap::new();
557 let mut response_body: Vec<u8> = Vec::new();
558 let mut body_set = false;
559 let mut decode_failed = false;
560
561 for protocol_result in execution_result.protocol_results {
562 if let ActionResult::Output(output_data) = protocol_result {
563 // Parse the output as JSON containing PyPI response fields
564 if let Ok(json_value) =
565 serde_json::from_slice::<serde_json::Value>(&output_data)
566 {
567 if let Some(status) = json_value.get("status").and_then(|v| v.as_u64()) {
568 if (100..=599).contains(&status) {
569 status_code = Some(status as u16);
570 } else {
571 Log::new(Some(&status_tx)).error(format!(
572 "PyPI response status {} is not a valid HTTP status code",
573 status
574 ));
575 decode_failed = true;
576 }
577 }
578 if let Some(headers_obj) =
579 json_value.get("headers").and_then(|v| v.as_object())
580 {
581 for (k, v) in headers_obj {
582 if let Some(v_str) = v.as_str() {
583 response_headers.insert(k.clone(), v_str.to_string());
584 }
585 }
586 }
587 // body_base64 is only ever set by the action executor,
588 // which has already validated the encoding.
589 if let Some(encoded) =
590 json_value.get("body_base64").and_then(|v| v.as_str())
591 {
592 use base64::Engine;
593 match base64::engine::general_purpose::STANDARD.decode(encoded) {
594 Ok(decoded) => {
595 response_body = decoded;
596 body_set = true;
597 }
598 Err(e) => {
599 // Serving 0 bytes here would hand pip a truncated
600 // distribution that fails its hash check much later,
601 // far from the cause. Refuse instead.
602 Log::new(Some(&status_tx)).error(format!(
603 "PyPI response body_base64 is not valid base64: {}",
604 e
605 ));
606 decode_failed = true;
607 }
608 }
609 } else if let Some(body) = json_value.get("body").and_then(|v| v.as_str()) {
610 response_body = body.as_bytes().to_vec();
611 body_set = true;
612 }
613 }
614 }
615 }
616
617 if decode_failed {
618 // The model/handler answered, but the answer cannot be put on the wire.
619 Log::new(Some(&status_tx)).error(format!(
620 "PyPI {} {} decision=fail_closed_bad_action → 502",
621 method, uri
622 ));
623 return Ok(bad_gateway());
624 }
625
626 // `send_pypi_response` always yields a status plus exactly one of
627 // `body`/`body_base64` (see `execute_send_pypi_response`), so both being
628 // present is precisely "a PyPI response was produced".
629 let Some(status_code) = status_code.filter(|_| body_set) else {
630 // Distinct from an LLM error (below) and from a model that deliberately
631 // answered 404: nothing usable came back at all.
632 Log::new(Some(&status_tx)).warn(format!(
633 "PyPI {} {} decision=fail_closed_no_action \
634 (no send_pypi_response in the answer) → 502",
635 method, uri
636 ));
637 return Ok(bad_gateway());
638 };
639
640 Log::new(Some(&status_tx)).info(format!(
641 "PyPI {} {} decision=model_response → {} ({} bytes)",
642 method,
643 uri,
644 status_code,
645 response_body.len()
646 ));
647
648 // Build the HTTP response. Status and header names come from model or
649 // handler output, so an invalid one must not take the connection down.
650 let mut response = Response::builder().status(status_code);
651
652 // Add headers
653 for (name, value) in response_headers {
654 response = response.header(name, value);
655 }
656
657 match response.body(Full::new(Bytes::from(response_body))) {
658 Ok(resp) => Ok(resp),
659 Err(e) => {
660 Log::new(Some(&status_tx)).error(format!(
661 "PyPI {} {} decision=fail_closed_bad_action: invalid response ({}) → 502",
662 method, uri, e
663 ));
664 Ok(bad_gateway())
665 }
666 }
667 }
668 Err(e) => {
669 // The backend failed. The peer gets a category and a code it can act on; the
670 // error itself — backend URL, model name, anyhow chain — goes to the log only.
671 let failure = crate::utils::WireFailure::classify(&e);
672 let code = if failure.is_overloaded() { 503 } else { 500 };
673 Log::new(Some(&status_tx)).warn(format!(
674 "PyPI {} {} decision=fail_closed_llm_error → {} ({:?}): {}",
675 method, uri, code, failure, e
676 ));
677 Ok(llm_failure_response(failure))
678 }
679 }
680}