Skip to main content

somatize_worker/
server.rs

1//! Axum HTTP/WebSocket server for the worker process.
2//!
3//! Supports optional bearer token authentication on WebSocket connections.
4//! Set a token via [`worker_router_authenticated`] or the `--token` CLI flag.
5
6use 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/// Resolves when the worker has been asked to stop.
25///
26/// A `Shutdown` message used to call `std::process::exit(0)` from inside the
27/// WebSocket handler. That is wrong twice over: it skips the graceful
28/// shutdown the binary wires up, and this crate is *embedded* — `Worker.serve()`
29/// runs this server on a thread of the user's Python process, so exiting
30/// killed their interpreter. Asking the serve loop to stop is the only
31/// thing a library may do.
32#[derive(Clone)]
33pub struct ShutdownSignal(Arc<tokio::sync::Notify>);
34
35impl ShutdownSignal {
36    /// Wait until shutdown is requested.
37    pub async fn wait(&self) {
38        self.0.notified().await;
39    }
40
41    /// Ask the worker to stop.
42    pub fn trigger(&self) {
43        self.0.notify_waiters();
44    }
45}
46
47/// Shared state for the worker HTTP/WebSocket server.
48struct ServerState {
49    worker: Mutex<Worker>,
50    /// Notified when a `Shutdown` message arrives.
51    shutdown: ShutdownSignal,
52    env_manager: EnvManager,
53    work_dir: PathBuf,
54    /// Optional bearer token for authentication.
55    token: Option<String>,
56    /// Temporary local store for HTTP bulk uploads.
57    temp_store: Arc<LocalDataStore>,
58    /// Track upload times for automatic cleanup.
59    temp_uploads: Mutex<HashMap<CacheKey, Instant>>,
60    /// Active streaming sessions, one driver + context alive between
61    /// WS messages — the state a chunked run must carry across RPCs.
62    active_streams: Mutex<HashMap<String, StreamSession>>,
63}
64
65/// One in-flight streaming run: the driver, its execution context, and
66/// the cache it reads/writes — held between `StreamBegin`, N ×
67/// `ChunkData` and `StreamEnd`.
68struct 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
75/// Build a worker server router (no authentication).
76pub fn worker_router(worker: Worker) -> Router {
77    worker_router_full(worker, "/tmp/soma-envs", "/tmp/soma-work", None)
78}
79
80/// Build a worker server router with custom directories.
81pub 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
89/// Build a worker server router with authentication.
90pub 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
108/// Build a worker router together with the handle that stops it.
109///
110/// Callers that own the serve loop (the binary, [`serve_worker`]) pass the
111/// signal to `with_graceful_shutdown` so a `Shutdown` message ends the
112/// server the same way Ctrl+C does.
113pub 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    // Background cleanup: remove temp uploads older than 1 hour. It stops
134    // with the server rather than outliving it — this task used to run
135    // forever, so a test that built a router leaked one per router.
136    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()) // No limit — workers handle arbitrary data sizes
181        .with_state(state);
182    (router, shutdown)
183}
184
185/// Start a worker server on the given address.
186pub 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
197/// Start a worker server with authentication.
198pub 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
227/// Upload data via HTTP for large payloads that exceed WebSocket limits.
228///
229/// Accepts msgpack or JSON body, stores in temp_store, returns DataRef as JSON.
230/// Token auth via `?token=` query param (same as WebSocket).
231async 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    // Validate token
237    if let Some(expected) = &state.token {
238        match &params.token {
239            Some(provided) if provided == expected => {}
240            _ => return Err(StatusCode::UNAUTHORIZED),
241        }
242    }
243
244    // Deserialize: try msgpack first, then JSON
245    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    // Track for cleanup
256    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/// Query params for data download.
270#[derive(serde::Deserialize)]
271struct DownloadParams {
272    /// JSON-serialized DataRef (same format returned by /upload).
273    #[serde(rename = "ref")]
274    data_ref: String,
275    token: Option<String>,
276}
277
278/// Download data from the worker's temp store by DataRef.
279///
280/// Returns msgpack-encoded Value. Used by clients to resolve
281/// `OutputDelivery::Reference` results after plan execution.
282async fn download_data(
283    Query(params): Query<DownloadParams>,
284    State(state): State<Arc<ServerState>>,
285) -> Result<impl IntoResponse, StatusCode> {
286    // Validate token
287    if let Some(expected) = &state.token {
288        match &params.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(&params.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/// Query params for WebSocket authentication.
311#[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    // Validate token if server requires one
322    if let Some(expected) = &state.token {
323        match &params.token {
324            Some(provided) if provided == expected => {}
325            _ => return Err(StatusCode::UNAUTHORIZED),
326        }
327    }
328    Ok(ws
329        .max_message_size(usize::MAX) // No limit on incoming WS messages
330        .max_frame_size(usize::MAX)
331        .on_upgrade(move |socket| handle_ws(socket, state)))
332}
333
334/// A well-formed error reply.
335///
336/// These used to be built by interpolating the error straight into a JSON
337/// string literal, so a message containing a quote or a backslash — which
338/// serde's own parse errors do contain — produced invalid JSON. The client
339/// then reported a parse failure instead of the failure that happened.
340///
341/// Still not a `WorkerToCoordinator` variant, so a client cannot match on
342/// it; that is a wire-protocol change and belongs with versioning it.
343fn error_reply(message: &str) -> String {
344    // A real protocol variant, not a bare `{"error": …}`. The client
345    // silently skips what it cannot parse, so an unparseable failure left
346    // it waiting for a reply that had already gone.
347    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                        // Off the reactor: executing a plan creates a venv,
360                        // pip-installs into it and drives a Python
361                        // subprocess to completion. Running that inline in
362                        // an async handler parked a tokio worker thread for
363                        // the whole plan, so `/health` and every other
364                        // connection stalled behind it.
365                        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                            // A panic no longer reaches the reactor; report
381                            // it as a failure of this plan and keep serving.
382                            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                        // Same reasoning as AssignPlan: this creates an env
394                        // and runs a subprocess.
395                        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                        // Send all but the last as intermediate messages
405                        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                        // Return the last message (result) through the normal path
415                        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                        // Ask the serve loop to wind down; do not exit the
425                        // process. This crate also runs inside the user's
426                        // Python interpreter.
427                        state.shutdown.trigger();
428                        break;
429                    }
430                    // These four used to be refused together with "not
431                    // implemented for SubprocessFilter". Every piece they
432                    // need was already written — the daemon script has
433                    // GET_STATE/SET_STATE/GET_GRADIENTS/APPLY_GRADIENTS and
434                    // `PythonProcess` has the methods — and nothing called
435                    // them, which is what kept DataParallel from running.
436                    //
437                    // Off the reactor, for the same reason `AssignPlan` is:
438                    // each one takes the worker's mutex and then talks to a
439                    // Python subprocess over a pipe. Doing that inline in an
440                    // async handler parks a tokio worker thread, and with
441                    // two of these in flight the runtime deadlocks — which
442                    // is exactly what it did.
443                    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                // A frame that will not decode is reported, not dropped.
517                // Swallowing it behind `if let Ok(..)` is what let a
518                // months-old encoding bug look like a chunk that simply
519                // never arrived.
520                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                // Chunk processing drives the same Python subprocess as
532                // a plan does, so it belongs off the reactor too.
533                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
567/// Handle a streaming protocol message. Returns an optional reply.
568fn 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            // Build StreamExecutor from the plan's filters
576            let mut worker = state.worker.lock().unwrap_or_else(|e| e.into_inner());
577
578            // Register filters via SubprocessFilter backed by a shared PythonProcess
579            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            // Build the stream driver over the registered filters. A node
613            // the worker cannot resolve fails the stream up front, rather
614            // than silently streaming a shorter chain.
615            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 // No reply for StreamBegin
645        }
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, // Barrier mode — no result yet
667                    Err(e) => {
668                        // The run is dead; drop the session so a retry
669                        // does not resume a half-failed one.
670                        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                // Flush barrier filters, then close each node's event
699                // bracket with its chunk/hit/miss aggregate.
700                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
730/// Execute a Python pipeline job with progress reporting.
731fn 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    // Phase 1/4: Environment setup
752    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    // Phase 2/4: Write files
775    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    // Phase 3/4: Execute
802    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    // Phase 4/4: Collect results
819    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}