1use crate::env_manager::{EnvManager, EnvType};
7use crate::protocol::*;
8use crate::worker::Worker;
9use axum::Router;
10use axum::extract::DefaultBodyLimit;
11use axum::extract::ws::{Message, WebSocket};
12use axum::extract::{Query, State, WebSocketUpgrade};
13use axum::http::StatusCode;
14use axum::response::IntoResponse;
15use axum::routing::{get, post};
16use somatize_core::cache::CacheKey;
17use somatize_core::store::{DataStore, LocalDataStore};
18use somatize_core::value::Value;
19use std::collections::HashMap;
20use std::path::PathBuf;
21use std::sync::{Arc, Mutex};
22use std::time::Instant;
23
24#[derive(Clone)]
33pub struct ShutdownSignal(Arc<tokio::sync::Notify>);
34
35impl ShutdownSignal {
36 pub async fn wait(&self) {
38 self.0.notified().await;
39 }
40
41 pub fn trigger(&self) {
43 self.0.notify_waiters();
44 }
45}
46
47struct ServerState {
49 worker: Mutex<Worker>,
50 shutdown: ShutdownSignal,
52 env_manager: EnvManager,
53 work_dir: PathBuf,
54 token: Option<String>,
56 temp_store: Arc<LocalDataStore>,
58 temp_uploads: Mutex<HashMap<CacheKey, Instant>>,
60 active_streams: Mutex<HashMap<String, StreamSession>>,
63}
64
65struct StreamSession {
69 run: somatize_runtime::StreamRun,
70 ctx: somatize_runtime::Context,
71 cache: std::sync::Arc<dyn somatize_core::cache::CacheStore>,
72 started: Instant,
73}
74
75pub fn worker_router(worker: Worker) -> Router {
77 worker_router_full(worker, "/tmp/soma-envs", "/tmp/soma-work", None)
78}
79
80pub fn worker_router_with_dirs(
82 worker: Worker,
83 env_dir: impl Into<PathBuf>,
84 work_dir: impl Into<PathBuf>,
85) -> Router {
86 worker_router_full(worker, env_dir, work_dir, None)
87}
88
89pub fn worker_router_authenticated(
91 worker: Worker,
92 env_dir: impl Into<PathBuf>,
93 work_dir: impl Into<PathBuf>,
94 token: impl Into<String>,
95) -> Router {
96 worker_router_full(worker, env_dir, work_dir, Some(token.into()))
97}
98
99fn worker_router_full(
100 worker: Worker,
101 env_dir: impl Into<PathBuf>,
102 work_dir: impl Into<PathBuf>,
103 token: Option<String>,
104) -> Router {
105 worker_router_with_shutdown(worker, env_dir, work_dir, token).0
106}
107
108pub fn worker_router_with_shutdown(
114 worker: Worker,
115 env_dir: impl Into<PathBuf>,
116 work_dir: impl Into<PathBuf>,
117 token: Option<String>,
118) -> (Router, ShutdownSignal) {
119 let work = work_dir.into();
120 std::fs::create_dir_all(&work).ok();
121 let temp_store = worker.temp_store().clone();
122 let shutdown = ShutdownSignal(Arc::new(tokio::sync::Notify::new()));
123 let state = Arc::new(ServerState {
124 worker: Mutex::new(worker),
125 shutdown: shutdown.clone(),
126 env_manager: EnvManager::new(env_dir, EnvType::Venv),
127 work_dir: work,
128 token,
129 temp_store,
130 temp_uploads: Mutex::new(HashMap::new()),
131 active_streams: Mutex::new(HashMap::new()),
132 });
133 let cleanup_state = state.clone();
137 let cleanup_shutdown = shutdown.clone();
138 tokio::spawn(async move {
139 let mut interval = tokio::time::interval(std::time::Duration::from_secs(300));
140 loop {
141 tokio::select! {
142 _ = interval.tick() => {}
143 _ = cleanup_shutdown.wait() => break,
144 }
145 let cutoff = Instant::now() - std::time::Duration::from_secs(3600);
146 let expired: Vec<CacheKey> = {
147 let uploads = cleanup_state
148 .temp_uploads
149 .lock()
150 .unwrap_or_else(|e| e.into_inner());
151 uploads
152 .iter()
153 .filter(|(_, created)| **created < cutoff)
154 .map(|(k, _)| k.clone())
155 .collect()
156 };
157 if !expired.is_empty() {
158 let mut uploads = cleanup_state
159 .temp_uploads
160 .lock()
161 .unwrap_or_else(|e| e.into_inner());
162 for key in &expired {
163 let data_ref = somatize_core::store::DataRef::Cached {
164 cache_key: key.clone(),
165 };
166 let _ = cleanup_state.temp_store.remove(&data_ref);
167 uploads.remove(key);
168 }
169 tracing::info!("Cleaned up {} expired temp uploads", expired.len());
170 }
171 }
172 });
173
174 let router = Router::new()
175 .route("/health", get(health))
176 .route("/info", get(info))
177 .route("/upload", post(upload_data))
178 .route("/download", get(download_data))
179 .route("/ws", get(ws_handler))
180 .layer(DefaultBodyLimit::disable()) .with_state(state);
182 (router, shutdown)
183}
184
185pub async fn serve_worker(worker: Worker, addr: &str) -> Result<(), Box<dyn std::error::Error>> {
187 let listener = tokio::net::TcpListener::bind(addr).await?;
188 tracing::info!("Worker server listening on {addr}");
189 let (router, shutdown) =
190 worker_router_with_shutdown(worker, "/tmp/soma-envs", "/tmp/soma-work", None);
191 axum::serve(listener, router)
192 .with_graceful_shutdown(async move { shutdown.wait().await })
193 .await?;
194 Ok(())
195}
196
197pub async fn serve_worker_authenticated(
199 worker: Worker,
200 addr: &str,
201 token: &str,
202) -> Result<(), Box<dyn std::error::Error>> {
203 let listener = tokio::net::TcpListener::bind(addr).await?;
204 tracing::info!("Worker server listening on {addr} (authenticated)");
205 let (router, shutdown) = worker_router_with_shutdown(
206 worker,
207 "/tmp/soma-envs",
208 "/tmp/soma-work",
209 Some(token.to_string()),
210 );
211 axum::serve(listener, router)
212 .with_graceful_shutdown(async move { shutdown.wait().await })
213 .await?;
214 Ok(())
215}
216
217async fn health() -> &'static str {
218 "ok"
219}
220
221async fn info(State(state): State<Arc<ServerState>>) -> impl IntoResponse {
222 let worker = state.worker.lock().unwrap_or_else(|e| e.into_inner());
223 let msg = worker.registration_message();
224 axum::Json(serde_json::to_value(msg).unwrap_or_default())
225}
226
227async fn upload_data(
232 Query(params): Query<WsParams>,
233 State(state): State<Arc<ServerState>>,
234 body: axum::body::Bytes,
235) -> Result<impl IntoResponse, StatusCode> {
236 if let Some(expected) = &state.token {
238 match ¶ms.token {
239 Some(provided) if provided == expected => {}
240 _ => return Err(StatusCode::UNAUTHORIZED),
241 }
242 }
243
244 let value: Value = rmp_serde::from_slice(&body)
246 .or_else(|_| serde_json::from_slice(&body))
247 .map_err(|_| StatusCode::BAD_REQUEST)?;
248
249 let key = CacheKey::hash_data(&body);
250 let data_ref = state
251 .temp_store
252 .put(&key, &value)
253 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
254
255 state
257 .temp_uploads
258 .lock()
259 .unwrap_or_else(|e| e.into_inner())
260 .insert(key, Instant::now());
261
262 tracing::info!("Uploaded {} bytes → {data_ref:?}", body.len());
263
264 Ok(axum::Json(
265 serde_json::to_value(&data_ref).unwrap_or_default(),
266 ))
267}
268
269#[derive(serde::Deserialize)]
271struct DownloadParams {
272 #[serde(rename = "ref")]
274 data_ref: String,
275 token: Option<String>,
276}
277
278async fn download_data(
283 Query(params): Query<DownloadParams>,
284 State(state): State<Arc<ServerState>>,
285) -> Result<impl IntoResponse, StatusCode> {
286 if let Some(expected) = &state.token {
288 match ¶ms.token {
289 Some(provided) if provided == expected => {}
290 _ => return Err(StatusCode::UNAUTHORIZED),
291 }
292 }
293
294 let data_ref: somatize_core::store::DataRef =
295 serde_json::from_str(¶ms.data_ref).map_err(|_| StatusCode::BAD_REQUEST)?;
296
297 let value = state
298 .temp_store
299 .get(&data_ref)
300 .map_err(|_| StatusCode::NOT_FOUND)?;
301
302 let bytes = serde_json::to_vec(&value).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
303
304 Ok((
305 [(axum::http::header::CONTENT_TYPE, "application/json")],
306 bytes,
307 ))
308}
309
310#[derive(serde::Deserialize, Default)]
312struct WsParams {
313 token: Option<String>,
314}
315
316async fn ws_handler(
317 ws: WebSocketUpgrade,
318 Query(params): Query<WsParams>,
319 State(state): State<Arc<ServerState>>,
320) -> Result<impl IntoResponse, StatusCode> {
321 if let Some(expected) = &state.token {
323 match ¶ms.token {
324 Some(provided) if provided == expected => {}
325 _ => return Err(StatusCode::UNAUTHORIZED),
326 }
327 }
328 Ok(ws
329 .max_message_size(usize::MAX) .max_frame_size(usize::MAX)
331 .on_upgrade(move |socket| handle_ws(socket, state)))
332}
333
334fn error_reply(message: &str) -> String {
344 serde_json::to_string(&WorkerToCoordinator::Error {
348 message: message.to_string(),
349 })
350 .unwrap_or_else(|_| r#"{"type":"Error","message":"unserializable error"}"#.to_string())
351}
352
353async fn handle_ws(mut socket: WebSocket, state: Arc<ServerState>) {
354 loop {
355 match socket.recv().await {
356 Some(Ok(Message::Text(text))) => {
357 let response = match serde_json::from_str::<CoordinatorToWorker>(&text) {
358 Ok(CoordinatorToWorker::AssignPlan { plan }) => {
359 let st = state.clone();
366 let joined = tokio::task::spawn_blocking(move || {
367 let mut worker = st.worker.lock().unwrap_or_else(|e| e.into_inner());
368 let plan_id = plan.plan_id.clone();
369 let worker_id = worker.id.clone();
370 let result = worker.execute_plan(&plan);
371 WorkerToCoordinator::PlanResult {
372 worker_id,
373 plan_id,
374 result,
375 }
376 })
377 .await;
378 match joined {
379 Ok(msg) => serde_json::to_string(&msg).unwrap_or_default(),
380 Err(e) => error_reply(&format!("plan execution panicked: {e}")),
383 }
384 }
385 Ok(CoordinatorToWorker::StatusRequest) => {
386 let worker = state.worker.lock().unwrap_or_else(|e| e.into_inner());
387 serde_json::to_string(&worker.registration_message()).unwrap_or_default()
388 }
389 Ok(CoordinatorToWorker::CancelPlan { .. }) => {
390 error_reply("cancelling a running plan is not implemented")
391 }
392 Ok(CoordinatorToWorker::AssignPythonJob { job }) => {
393 let st = state.clone();
396 let messages = match tokio::task::spawn_blocking(move || {
397 execute_python_job_with_progress(&st, &job)
398 })
399 .await
400 {
401 Ok(messages) => messages,
402 Err(e) => vec![error_reply(&format!("python job panicked: {e}"))],
403 };
404 for msg in &messages[..messages.len().saturating_sub(1)] {
406 if socket
407 .send(Message::Text(msg.clone().into()))
408 .await
409 .is_err()
410 {
411 break;
412 }
413 }
414 messages.into_iter().last().unwrap_or_default()
416 }
417 Ok(CoordinatorToWorker::Ping) => r#"{"type":"Pong"}"#.to_string(),
418 Ok(CoordinatorToWorker::Registered { .. }) => continue,
419 Ok(CoordinatorToWorker::Shutdown { reason }) => {
420 tracing::info!("Shutdown requested: {reason}");
421 let _ = socket
422 .send(Message::Text(r#"{"type":"ShutdownAck"}"#.into()))
423 .await;
424 state.shutdown.trigger();
428 break;
429 }
430 Ok(
444 msg @ (CoordinatorToWorker::GetState { .. }
445 | CoordinatorToWorker::SetState { .. }
446 | CoordinatorToWorker::GetGradients { .. }
447 | CoordinatorToWorker::ApplyGradients { .. }),
448 ) => {
449 let st = state.clone();
450 let joined = tokio::task::spawn_blocking(move || {
451 let mut worker = st.worker.lock().unwrap_or_else(|e| e.into_inner());
452 let id = worker.id.clone();
453 match msg {
454 CoordinatorToWorker::GetState { plan_id, node_ids } => {
455 match worker.read_states(&node_ids) {
456 Ok(states) => serde_json::to_string(
457 &WorkerToCoordinator::StateResult {
458 worker_id: id,
459 plan_id,
460 states,
461 },
462 )
463 .unwrap_or_default(),
464 Err(e) => error_reply(&e.to_string()),
465 }
466 }
467 CoordinatorToWorker::SetState { states, .. } => {
468 match worker.write_states(&states) {
469 Ok(()) => {
470 serde_json::to_string(&WorkerToCoordinator::Ack {
471 worker_id: id,
472 })
473 .unwrap_or_default()
474 }
475 Err(e) => error_reply(&e.to_string()),
476 }
477 }
478 CoordinatorToWorker::GetGradients { plan_id, node_ids } => {
479 match worker.read_gradients(&node_ids) {
480 Ok(gradients) => serde_json::to_string(
481 &WorkerToCoordinator::GradientsResult {
482 worker_id: id,
483 plan_id,
484 gradients,
485 },
486 )
487 .unwrap_or_default(),
488 Err(e) => error_reply(&e.to_string()),
489 }
490 }
491 CoordinatorToWorker::ApplyGradients { gradients, .. } => {
492 match worker.write_gradients(&gradients) {
493 Ok(()) => {
494 serde_json::to_string(&WorkerToCoordinator::Ack {
495 worker_id: id,
496 })
497 .unwrap_or_default()
498 }
499 Err(e) => error_reply(&e.to_string()),
500 }
501 }
502 _ => unreachable!("guarded by the match arm above"),
503 }
504 })
505 .await;
506 joined.unwrap_or_else(|e| error_reply(&format!("worker task: {e}")))
507 }
508 Err(e) => error_reply(&format!("invalid message: {e}")),
509 };
510
511 if socket.send(Message::Text(response.into())).await.is_err() {
512 break;
513 }
514 }
515 Some(Ok(Message::Binary(bytes))) => {
516 let stream_msg = match crate::protocol::decode_frame(&bytes) {
521 Ok(msg) => msg,
522 Err(e) => {
523 tracing::error!("{e}");
524 let _ = socket
525 .send(Message::Text(error_reply(&e.to_string()).into()))
526 .await;
527 continue;
528 }
529 };
530
531 let st = state.clone();
534 let reply =
535 tokio::task::spawn_blocking(move || handle_stream_message(stream_msg, &st))
536 .await
537 .unwrap_or_else(|e| {
538 tracing::error!("stream message handler panicked: {e}");
539 None
540 });
541 if let Some(reply_msg) = reply {
542 match crate::protocol::encode_frame(&reply_msg) {
543 Ok(reply_bytes) => {
544 if socket
545 .send(Message::Binary(reply_bytes.into()))
546 .await
547 .is_err()
548 {
549 break;
550 }
551 }
552 Err(e) => {
553 tracing::error!("{e}");
554 let _ = socket
555 .send(Message::Text(error_reply(&e.to_string()).into()))
556 .await;
557 }
558 }
559 }
560 }
561 Some(Ok(Message::Close(_))) | None => break,
562 _ => {}
563 }
564 }
565}
566
567fn handle_stream_message(msg: StreamMessage, state: &Arc<ServerState>) -> Option<StreamMessage> {
569 use somatize_runtime::{Context, StreamRun};
570
571 match msg {
572 StreamMessage::StreamBegin {
573 stream_id, plan, ..
574 } => {
575 let mut worker = state.worker.lock().unwrap_or_else(|e| e.into_inner());
577
578 let filter_specs: Vec<(String, Vec<u8>, bool)> = plan
580 .filters
581 .iter()
582 .map(|sf| (sf.node_id.clone(), sf.pickled_filter.clone(), sf.trainable))
583 .collect();
584
585 if !filter_specs.is_empty() {
586 let process = Arc::new(std::sync::Mutex::new(
587 crate::python_process::PythonProcess::spawn("python3", &filter_specs)
588 .expect("PythonProcess spawn failed"),
589 ));
590
591 for sf in &plan.filters {
592 let config_hash = sf.config_hash.clone().unwrap_or_else(|| {
593 crate::python_process::SubprocessFilter::fallback_config_hash(
594 &sf.node_id,
595 &sf.pickled_filter,
596 )
597 });
598 let filter: Box<dyn somatize_core::filter::Filter> =
599 Box::new(crate::python_process::SubprocessFilter::new(
600 process.clone(),
601 sf.node_id.clone(),
602 sf.trainable,
603 config_hash,
604 ));
605 worker.register_filter(&sf.node_id, filter);
606 if let Some(s) = &sf.state {
607 worker.set_filter_state(&sf.node_id, s.clone());
608 }
609 }
610 }
611
612 let node_ids: Vec<String> =
616 plan.plan.node_ids().into_iter().map(String::from).collect();
617 let run = match StreamRun::new(&node_ids, worker.catalog()) {
618 Ok(run) => run,
619 Err(e) => {
620 return Some(StreamMessage::StreamComplete {
621 stream_id,
622 result: PlanResult::Failed {
623 error: e.to_string(),
624 duration_ms: 0,
625 },
626 });
627 }
628 };
629
630 let run_id = format!("worker_stream_{stream_id}");
631 let ctx = Context::new(worker.event_bus().clone(), run_id).with_seed(plan.seed);
632 let session = StreamSession {
633 run,
634 ctx,
635 cache: worker.cache().clone(),
636 started: Instant::now(),
637 };
638 state
639 .active_streams
640 .lock()
641 .unwrap_or_else(|e| e.into_inner())
642 .insert(stream_id, session);
643
644 None }
646 StreamMessage::ChunkData {
647 stream_id,
648 chunk_index,
649 value,
650 } => {
651 let mut streams = state
652 .active_streams
653 .lock()
654 .unwrap_or_else(|e| e.into_inner());
655 if let Some(session) = streams.get_mut(&stream_id) {
656 let outcome =
657 session
658 .run
659 .process_chunk(value, &mut session.ctx, session.cache.as_ref());
660 match outcome {
661 Ok(Some(result)) => Some(StreamMessage::ChunkResult {
662 stream_id,
663 chunk_index,
664 value: result,
665 }),
666 Ok(None) => None, Err(e) => {
668 let duration_ms = session.started.elapsed().as_millis() as u64;
671 streams.remove(&stream_id);
672 Some(StreamMessage::StreamComplete {
673 stream_id,
674 result: PlanResult::Failed {
675 error: e.to_string(),
676 duration_ms,
677 },
678 })
679 }
680 }
681 } else {
682 Some(StreamMessage::StreamComplete {
683 stream_id,
684 result: PlanResult::Failed {
685 error: "unknown stream_id".to_string(),
686 duration_ms: 0,
687 },
688 })
689 }
690 }
691 StreamMessage::StreamEnd { stream_id } => {
692 let mut streams = state
693 .active_streams
694 .lock()
695 .unwrap_or_else(|e| e.into_inner());
696 if let Some(mut session) = streams.remove(&stream_id) {
697 let duration_ms = session.started.elapsed().as_millis() as u64;
698 let flushed = session.run.flush(&mut session.ctx, session.cache.as_ref());
701 let output = match flushed {
702 Ok(v) => v.unwrap_or(somatize_core::value::Value::Empty),
703 Err(e) => {
704 return Some(StreamMessage::StreamComplete {
705 stream_id,
706 result: PlanResult::Failed {
707 error: format!("stream flush: {e}"),
708 duration_ms,
709 },
710 });
711 }
712 };
713 session.run.finish(&session.ctx);
714 Some(StreamMessage::StreamComplete {
715 stream_id,
716 result: PlanResult::Success {
717 output: OutputDelivery::Inline { value: output },
718 duration_ms,
719 states: std::collections::HashMap::new(),
720 },
721 })
722 } else {
723 None
724 }
725 }
726 _ => None,
727 }
728}
729
730fn execute_python_job_with_progress(state: &ServerState, job: &PythonPipelineJob) -> Vec<String> {
732 let start = Instant::now();
733 let mut messages = Vec::new();
734 let worker_id = {
735 let w = state.worker.lock().unwrap_or_else(|e| e.into_inner());
736 w.id.clone()
737 };
738
739 let progress = |wid: &str, jid: &str, phase: &str, step: u32, total: u32| -> String {
740 serde_json::to_string(&WorkerToCoordinator::JobProgress {
741 worker_id: wid.into(),
742 job_id: jid.into(),
743 phase: phase.into(),
744 step,
745 total,
746 metrics: serde_json::json!({}),
747 })
748 .unwrap_or_default()
749 };
750
751 messages.push(progress(&worker_id, &job.job_id, "environment", 1, 4));
753
754 let python = match state
755 .env_manager
756 .ensure_env(&job.pipeline_id, &job.requirements)
757 {
758 Ok(p) => p,
759 Err(e) => {
760 tracing::error!("Failed to create env for pipeline {}: {e}", job.pipeline_id);
761 let msg = WorkerToCoordinator::JobResult {
762 worker_id,
763 job_id: job.job_id.clone(),
764 success: false,
765 metrics: serde_json::json!({}),
766 output: format!("Environment setup failed: {e}"),
767 duration_ms: start.elapsed().as_millis() as u64,
768 };
769 messages.push(serde_json::to_string(&msg).unwrap_or_default());
770 return messages;
771 }
772 };
773
774 messages.push(progress(&worker_id, &job.job_id, "write_files", 2, 4));
776
777 let job_dir = state.work_dir.join(format!("job-{}", job.job_id));
778 if let Err(e) = std::fs::create_dir_all(&job_dir) {
779 let msg = WorkerToCoordinator::JobResult {
780 worker_id,
781 job_id: job.job_id.clone(),
782 success: false,
783 metrics: serde_json::json!({}),
784 output: format!("Failed to create work dir: {e}"),
785 duration_ms: start.elapsed().as_millis() as u64,
786 };
787 messages.push(serde_json::to_string(&msg).unwrap_or_default());
788 return messages;
789 }
790
791 for file in &job.files {
792 let file_path = job_dir.join(&file.path);
793 if let Some(parent) = file_path.parent() {
794 std::fs::create_dir_all(parent).ok();
795 }
796 if let Err(e) = std::fs::write(&file_path, &file.content) {
797 tracing::error!("Failed to write {}: {e}", file.path);
798 }
799 }
800
801 messages.push(progress(&worker_id, &job.job_id, "execute", 3, 4));
803
804 tracing::info!(
805 "Executing job {} with python: {}",
806 job.job_id,
807 python.display()
808 );
809
810 let output = std::process::Command::new(&python)
811 .arg(&job.entry_point)
812 .current_dir(&job_dir)
813 .env("PYTHONPATH", &job_dir)
814 .output();
815
816 let duration_ms = start.elapsed().as_millis() as u64;
817
818 let _ = std::fs::remove_dir_all(&job_dir);
820 messages.push(progress(&worker_id, &job.job_id, "collect_results", 4, 4));
821
822 let result_msg = match output {
823 Ok(out) => {
824 let stdout = String::from_utf8_lossy(&out.stdout).to_string();
825 let stderr = String::from_utf8_lossy(&out.stderr).to_string();
826 let success = out.status.success();
827
828 let metrics = stdout
829 .lines()
830 .rev()
831 .find_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
832 .unwrap_or(serde_json::json!({}));
833
834 if !success {
835 tracing::warn!(
836 "Job {} failed: {}",
837 job.job_id,
838 stderr.chars().take(200).collect::<String>()
839 );
840 }
841
842 WorkerToCoordinator::JobResult {
843 worker_id,
844 job_id: job.job_id.clone(),
845 success,
846 metrics,
847 output: if success {
848 stdout
849 } else {
850 format!("STDERR:\n{stderr}\nSTDOUT:\n{stdout}")
851 },
852 duration_ms,
853 }
854 }
855 Err(e) => WorkerToCoordinator::JobResult {
856 worker_id,
857 job_id: job.job_id.clone(),
858 success: false,
859 metrics: serde_json::json!({}),
860 output: format!("Failed to execute: {e}"),
861 duration_ms,
862 },
863 };
864 messages.push(serde_json::to_string(&result_msg).unwrap_or_default());
865 messages
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871 use crate::protocol::Capabilities;
872 fn make_worker() -> Worker {
873 Worker::new(
874 "test_worker",
875 Capabilities {
876 cpu_cores: 4,
877 ram_bytes: 8_000_000_000,
878 gpus: vec![],
879 python_envs: vec![],
880 tags: vec!["test".into()],
881 },
882 )
883 }
884
885 #[tokio::test]
886 async fn router_builds() {
887 let _router = worker_router(make_worker());
888 }
889
890 #[tokio::test]
891 async fn health_returns_ok() {
892 let resp = health().await;
893 assert_eq!(resp, "ok");
894 }
895
896 #[tokio::test]
897 async fn full_server_starts_and_stops() {
898 let worker = make_worker();
899 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
900 let addr = listener.local_addr().unwrap();
901
902 let server = tokio::spawn(async move {
903 axum::serve(listener, worker_router(worker)).await.unwrap();
904 });
905
906 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
907
908 let client = reqwest::Client::new();
909 let resp = client
910 .get(format!("http://{addr}/health"))
911 .send()
912 .await
913 .unwrap();
914 assert_eq!(resp.text().await.unwrap(), "ok");
915
916 let resp = client
917 .get(format!("http://{addr}/info"))
918 .send()
919 .await
920 .unwrap();
921 let json: serde_json::Value = resp.json().await.unwrap();
922 assert!(json.get("type").is_some() || json.get("worker_id").is_some());
923
924 server.abort();
925 }
926}