1use std::collections::{HashMap, VecDeque};
4use std::sync::{Arc, Mutex};
5
6use crate::internal::runtime::{RunController, Worker, WorkerConnection, WorkerTransport};
7use async_trait::async_trait;
8use futures_util::{SinkExt, StreamExt};
9use http::header::AUTHORIZATION;
10use microclaw_core::run_protocol::{
11 RuntimeError, RuntimeErrorCode, WorkerCommand, WorkerFrame, WORKER_PROTOCOL_VERSION,
12};
13use tokio::net::{TcpListener, TcpStream};
14use tokio_tungstenite::tungstenite::client::IntoClientRequest;
15use tokio_tungstenite::tungstenite::handshake::server::{Request, Response};
16use tokio_tungstenite::tungstenite::http::StatusCode;
17use tokio_tungstenite::tungstenite::{Error as WebSocketError, Message};
18use tokio_tungstenite::{accept_hdr_async, connect_async, MaybeTlsStream, WebSocketStream};
19
20#[derive(Debug, Clone)]
22pub struct WebSocketWorkerTransport {
23 url: String,
24 bearer_token: Option<String>,
25}
26
27impl WebSocketWorkerTransport {
28 pub fn new(url: impl Into<String>) -> Self {
29 Self {
30 url: url.into(),
31 bearer_token: None,
32 }
33 }
34
35 pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
36 self.bearer_token = Some(token.into());
37 self
38 }
39}
40
41struct WebSocketConnection {
42 socket: WebSocketStream<MaybeTlsStream<TcpStream>>,
43}
44
45#[async_trait]
46impl WorkerTransport for WebSocketWorkerTransport {
47 async fn connect(&self) -> Result<Box<dyn WorkerConnection>, RuntimeError> {
48 let mut request = self
49 .url
50 .as_str()
51 .into_client_request()
52 .map_err(configuration_error)?;
53 if let Some(token) = &self.bearer_token {
54 let value = format!("Bearer {token}")
55 .parse()
56 .map_err(|_| configuration_error("invalid Worker bearer token"))?;
57 request.headers_mut().insert(AUTHORIZATION, value);
58 }
59 let (socket, _) = connect_async(request).await.map_err(connection_error)?;
60 Ok(Box::new(WebSocketConnection { socket }))
61 }
62}
63
64#[async_trait]
65impl WorkerConnection for WebSocketConnection {
66 async fn send(&mut self, command: WorkerCommand) -> Result<(), RuntimeError> {
67 let payload = serde_json::to_string(&command).map_err(protocol_error)?;
68 self.socket
69 .send(Message::Text(payload))
70 .await
71 .map_err(connection_error)
72 }
73
74 async fn receive(&mut self) -> Result<Option<WorkerFrame>, RuntimeError> {
75 while let Some(message) = self.socket.next().await {
76 match message.map_err(connection_error)? {
77 Message::Text(payload) => {
78 return serde_json::from_str(&payload)
79 .map(Some)
80 .map_err(protocol_error);
81 }
82 Message::Binary(payload) => {
83 return serde_json::from_slice(&payload)
84 .map(Some)
85 .map_err(protocol_error);
86 }
87 Message::Ping(payload) => self
88 .socket
89 .send(Message::Pong(payload))
90 .await
91 .map_err(connection_error)?,
92 Message::Close(_) => return Ok(None),
93 Message::Pong(_) | Message::Frame(_) => {}
94 }
95 }
96 Ok(None)
97 }
98}
99
100pub struct WorkerHost {
102 worker: Arc<dyn Worker>,
103 bearer_token: Option<String>,
104 runs: Arc<tokio::sync::Mutex<HostedRunRegistry>>,
105 max_retained_runs: usize,
106 max_events_per_run: usize,
107}
108
109#[derive(Default)]
110struct HostedRunRegistry {
111 runs: HashMap<String, Arc<HostedRun>>,
112 completed: VecDeque<String>,
113}
114
115struct HostedRun {
116 request_fingerprint: String,
117 controller: RunController,
118 state: Mutex<HostedRunState>,
119 changed: tokio::sync::Notify,
120}
121
122#[derive(Default)]
123struct HostedRunState {
124 events: VecDeque<microclaw_core::runtime_event::RuntimeEventEnvelope>,
125 discarded_through: Option<u64>,
126 result: Option<microclaw_core::run_protocol::RunResult>,
127}
128
129impl WorkerHost {
130 pub fn new(worker: impl Worker + 'static) -> Self {
131 Self {
132 worker: Arc::new(worker),
133 bearer_token: None,
134 runs: Arc::new(tokio::sync::Mutex::new(HostedRunRegistry::default())),
135 max_retained_runs: 256,
136 max_events_per_run: 4096,
137 }
138 }
139
140 pub fn shared(worker: Arc<dyn Worker>) -> Self {
141 Self {
142 worker,
143 bearer_token: None,
144 runs: Arc::new(tokio::sync::Mutex::new(HostedRunRegistry::default())),
145 max_retained_runs: 256,
146 max_events_per_run: 4096,
147 }
148 }
149
150 pub fn bearer_token(mut self, token: impl Into<String>) -> Self {
151 self.bearer_token = Some(token.into());
152 self
153 }
154
155 pub fn replay_limits(mut self, retained_runs: usize, events_per_run: usize) -> Self {
156 self.max_retained_runs = retained_runs.max(1);
157 self.max_events_per_run = events_per_run.max(1);
158 self
159 }
160
161 pub async fn serve(self, listener: TcpListener) -> Result<(), RuntimeError> {
163 let worker = self.worker;
164 let runs = self.runs;
165 let max_retained_runs = self.max_retained_runs;
166 let max_events_per_run = self.max_events_per_run;
167 let bearer_token = self.bearer_token.map(|token| format!("Bearer {token}"));
168 loop {
169 let (stream, _) = listener.accept().await.map_err(connection_error)?;
170 let worker = worker.clone();
171 let runs = runs.clone();
172 let bearer_token = bearer_token.clone();
173 tokio::spawn(async move {
174 let _ = serve_connection(
175 stream,
176 worker,
177 runs,
178 bearer_token,
179 max_retained_runs,
180 max_events_per_run,
181 )
182 .await;
183 });
184 }
185 }
186}
187
188#[allow(clippy::result_large_err)]
189async fn serve_connection(
190 stream: TcpStream,
191 worker: Arc<dyn Worker>,
192 runs: Arc<tokio::sync::Mutex<HostedRunRegistry>>,
193 bearer_token: Option<String>,
194 max_retained_runs: usize,
195 max_events_per_run: usize,
196) -> Result<(), RuntimeError> {
197 let socket = accept_hdr_async(stream, move |request: &Request, mut response: Response| {
198 let authorized = bearer_token.as_ref().is_none_or(|expected| {
199 request
200 .headers()
201 .get(AUTHORIZATION)
202 .and_then(|value| value.to_str().ok())
203 .is_some_and(|actual| constant_time_eq(actual.as_bytes(), expected.as_bytes()))
204 });
205 if authorized {
206 Ok(response)
207 } else {
208 *response.status_mut() = StatusCode::UNAUTHORIZED;
209 Err(response.map(|_| Some("unauthorized".into())))
210 }
211 })
212 .await
213 .map_err(connection_error)?;
214 let (mut sink, mut stream) = socket.split();
215
216 while let Some(message) = stream.next().await {
217 let command = decode_command(message.map_err(connection_error)?)?;
218 command.validate_protocol()?;
219 match command {
220 WorkerCommand::Describe { .. } => {
221 send_frame(
222 &mut sink,
223 WorkerFrame::Descriptor {
224 protocol_version: WORKER_PROTOCOL_VERSION,
225 descriptor: worker.descriptor(),
226 },
227 )
228 .await?;
229 }
230 WorkerCommand::Health { .. } => {
231 send_frame(
232 &mut sink,
233 WorkerFrame::Health {
234 protocol_version: WORKER_PROTOCOL_VERSION,
235 health: worker.health(),
236 },
237 )
238 .await?;
239 }
240 WorkerCommand::Submit {
241 profile, request, ..
242 } => {
243 let run_id = request
244 .run_id
245 .clone()
246 .ok_or_else(|| protocol_error("remote submission requires run_id"))?;
247 let request_fingerprint =
248 serde_json::to_string(&(profile.clone(), &request)).map_err(protocol_error)?;
249 let mut registry = runs.lock().await;
250 let hosted = if let Some(existing) = registry.runs.get(run_id.as_str()).cloned() {
251 if existing.request_fingerprint != request_fingerprint {
252 send_frame(
253 &mut sink,
254 WorkerFrame::Error {
255 protocol_version: WORKER_PROTOCOL_VERSION,
256 run_id: Some(run_id),
257 error: protocol_error(
258 "run_id was already submitted with a different request",
259 ),
260 },
261 )
262 .await?;
263 continue;
264 }
265 existing
266 } else {
267 let run = worker.submit(profile, *request).await?;
268 let hosted = Arc::new(HostedRun {
269 request_fingerprint,
270 controller: run.controller(),
271 state: Mutex::new(HostedRunState::default()),
272 changed: tokio::sync::Notify::new(),
273 });
274 registry.runs.insert(run_id.to_string(), hosted.clone());
275 tokio::spawn(pump_run(
276 run,
277 hosted.clone(),
278 runs.clone(),
279 max_retained_runs,
280 max_events_per_run,
281 ));
282 hosted
283 };
284 drop(registry);
285 send_frame(
286 &mut sink,
287 WorkerFrame::Accepted {
288 protocol_version: WORKER_PROTOCOL_VERSION,
289 run_id: run_id.clone(),
290 },
291 )
292 .await?;
293 stream_hosted_run(&mut sink, &mut stream, hosted, run_id, None).await?;
294 return Ok(());
295 }
296 WorkerCommand::Control { .. } => {
297 return Err(protocol_error("control command has no active run"));
298 }
299 WorkerCommand::ResumeEvents {
300 run_id,
301 after_sequence,
302 ..
303 } => {
304 let hosted = runs.lock().await.runs.get(run_id.as_str()).cloned();
305 let Some(hosted) = hosted else {
306 send_frame(
307 &mut sink,
308 WorkerFrame::Error {
309 protocol_version: WORKER_PROTOCOL_VERSION,
310 run_id: Some(run_id),
311 error: RuntimeError {
312 code: RuntimeErrorCode::Unavailable,
313 message: "run is not known by this Worker host".into(),
314 retryable: true,
315 },
316 },
317 )
318 .await?;
319 continue;
320 };
321 stream_hosted_run(&mut sink, &mut stream, hosted, run_id, after_sequence).await?;
322 return Ok(());
323 }
324 }
325 }
326 Ok(())
327}
328
329async fn pump_run(
330 mut run: crate::internal::runtime::RunHandle,
331 hosted: Arc<HostedRun>,
332 registry: Arc<tokio::sync::Mutex<HostedRunRegistry>>,
333 max_retained_runs: usize,
334 max_events_per_run: usize,
335) {
336 while let Some(envelope) = run.next_event().await {
337 let mut state = hosted
338 .state
339 .lock()
340 .unwrap_or_else(|error| error.into_inner());
341 state.events.push_back(envelope);
342 while state.events.len() > max_events_per_run {
343 state.discarded_through = state.events.pop_front().map(|event| event.sequence);
344 }
345 drop(state);
346 hosted.changed.notify_waiters();
347 }
348 let result =
349 run.result()
350 .await
351 .unwrap_or_else(|error| microclaw_core::run_protocol::RunResult {
352 run_id: hosted.controller.id().clone(),
353 session_id: microclaw_core::run_protocol::SessionId::new(""),
354 status: microclaw_core::run_protocol::RunStatus::Failed,
355 final_text: String::new(),
356 error: Some(error),
357 metadata: Default::default(),
358 });
359 hosted
360 .state
361 .lock()
362 .unwrap_or_else(|error| error.into_inner())
363 .result = Some(result);
364 hosted.changed.notify_waiters();
365 let run_id = hosted.controller.id().to_string();
366 let mut registry = registry.lock().await;
367 registry.completed.push_back(run_id);
368 while registry.completed.len() > max_retained_runs {
369 if let Some(expired) = registry.completed.pop_front() {
370 registry.runs.remove(&expired);
371 }
372 }
373}
374
375async fn stream_hosted_run<S, R>(
376 sink: &mut S,
377 stream: &mut R,
378 hosted: Arc<HostedRun>,
379 run_id: microclaw_core::run_protocol::RunId,
380 after_sequence: Option<u64>,
381) -> Result<(), RuntimeError>
382where
383 S: futures_util::Sink<Message, Error = WebSocketError> + Unpin,
384 R: futures_util::Stream<Item = Result<Message, WebSocketError>> + Unpin,
385{
386 let mut cursor = after_sequence;
387 loop {
388 let (events, result, history_lost) = {
389 let state = hosted
390 .state
391 .lock()
392 .unwrap_or_else(|error| error.into_inner());
393 (
394 state
395 .events
396 .iter()
397 .filter(|event| cursor.is_none_or(|value| event.sequence > value))
398 .cloned()
399 .collect::<Vec<_>>(),
400 state.result.clone(),
401 state
402 .discarded_through
403 .is_some_and(|discarded| cursor.is_none_or(|sequence| sequence < discarded)),
404 )
405 };
406 if history_lost {
407 send_frame(
408 sink,
409 WorkerFrame::Error {
410 protocol_version: WORKER_PROTOCOL_VERSION,
411 run_id: Some(run_id),
412 error: RuntimeError {
413 code: RuntimeErrorCode::Unavailable,
414 message: "requested Worker event history has expired".into(),
415 retryable: true,
416 },
417 },
418 )
419 .await?;
420 return Ok(());
421 }
422 for envelope in events {
423 cursor = Some(envelope.sequence);
424 send_frame(
425 sink,
426 WorkerFrame::Event {
427 protocol_version: WORKER_PROTOCOL_VERSION,
428 envelope,
429 },
430 )
431 .await?;
432 }
433 if let Some(result) = result {
434 send_frame(
435 sink,
436 WorkerFrame::Result {
437 protocol_version: WORKER_PROTOCOL_VERSION,
438 result,
439 },
440 )
441 .await?;
442 return Ok(());
443 }
444 tokio::select! {
445 _ = hosted.changed.notified() => {}
446 message = stream.next() => {
447 let Some(message) = message else { return Err(connection_error("Worker client disconnected")); };
448 let command = decode_command(message.map_err(connection_error)?)?;
449 command.validate_protocol()?;
450 let WorkerCommand::Control { control, .. } = command else {
451 send_frame(sink, WorkerFrame::Error { protocol_version: WORKER_PROTOCOL_VERSION, run_id: Some(run_id.clone()), error: protocol_error("only control commands are accepted during a run") }).await?;
452 continue;
453 };
454 let outcome = match control {
455 microclaw_core::run_protocol::RuntimeControl::Cancel { run_id: target } if target == run_id => hosted.controller.cancel_confirmed().await,
456 microclaw_core::run_protocol::RuntimeControl::Steer { run_id: target, message } if target == run_id => hosted.controller.steer_confirmed(message).await,
457 microclaw_core::run_protocol::RuntimeControl::ResolveApproval { run_id: target, approval_id, decision } if target == run_id => hosted.controller.resolve_approval_confirmed(approval_id, decision).await,
458 _ => Err(protocol_error("control command targets a different run")),
459 };
460 match outcome {
461 Ok(()) => send_frame(sink, WorkerFrame::ControlAcknowledged { protocol_version: WORKER_PROTOCOL_VERSION, run_id: run_id.clone() }).await?,
462 Err(error) => send_frame(sink, WorkerFrame::Error { protocol_version: WORKER_PROTOCOL_VERSION, run_id: Some(run_id.clone()), error }).await?,
463 }
464 }
465 }
466 }
467}
468
469fn decode_command(message: Message) -> Result<WorkerCommand, RuntimeError> {
470 match message {
471 Message::Text(payload) => serde_json::from_str(&payload).map_err(protocol_error),
472 Message::Binary(payload) => serde_json::from_slice(&payload).map_err(protocol_error),
473 _ => Err(protocol_error("expected a JSON Worker command")),
474 }
475}
476
477async fn send_frame<S>(sink: &mut S, frame: WorkerFrame) -> Result<(), RuntimeError>
478where
479 S: futures_util::Sink<Message, Error = WebSocketError> + Unpin,
480{
481 let payload = serde_json::to_string(&frame).map_err(protocol_error)?;
482 sink.send(Message::Text(payload))
483 .await
484 .map_err(connection_error)
485}
486
487fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
488 if left.len() != right.len() {
489 return false;
490 }
491 left.iter()
492 .zip(right)
493 .fold(0_u8, |diff, (left, right)| diff | (left ^ right))
494 == 0
495}
496
497fn configuration_error(error: impl std::fmt::Display) -> RuntimeError {
498 RuntimeError {
499 code: RuntimeErrorCode::Configuration,
500 message: error.to_string(),
501 retryable: false,
502 }
503}
504
505fn protocol_error(error: impl std::fmt::Display) -> RuntimeError {
506 RuntimeError {
507 code: RuntimeErrorCode::InvalidRequest,
508 message: error.to_string(),
509 retryable: false,
510 }
511}
512
513fn connection_error(error: impl std::fmt::Display) -> RuntimeError {
514 RuntimeError {
515 code: RuntimeErrorCode::Unavailable,
516 message: error.to_string(),
517 retryable: true,
518 }
519}
520
521#[cfg(test)]
522mod tests {
523 use crate::internal::runtime::{
524 ExecutionContext, ExecutionResult, LocalWorker, RemoteWorker, RunExecutor, Runtime,
525 };
526 use microclaw_core::run_protocol::{
527 AgentProfile, RunRequest, RuntimeCapabilities, SessionId, WorkerHealthStatus,
528 };
529 use microclaw_core::runtime_event::RuntimeEvent;
530 use std::sync::atomic::{AtomicUsize, Ordering};
531
532 use super::*;
533
534 struct Echo;
535
536 #[async_trait]
537 impl RunExecutor for Echo {
538 async fn execute(
539 &self,
540 _profile: AgentProfile,
541 request: RunRequest,
542 context: ExecutionContext,
543 ) -> Result<ExecutionResult, RuntimeError> {
544 context.emit(RuntimeEvent::TextDelta {
545 delta: "from worker".into(),
546 })?;
547 Ok(ExecutionResult::new(
548 request
549 .session_id
550 .unwrap_or_else(|| SessionId::new("remote-session")),
551 request.prompt,
552 ))
553 }
554
555 fn capabilities(&self) -> RuntimeCapabilities {
556 RuntimeCapabilities {
557 streaming: true,
558 cancellation: true,
559 remote_workers: true,
560 ..RuntimeCapabilities::default()
561 }
562 }
563 }
564
565 struct CountingEcho(Arc<AtomicUsize>);
566
567 #[async_trait]
568 impl RunExecutor for CountingEcho {
569 async fn execute(
570 &self,
571 _profile: AgentProfile,
572 request: RunRequest,
573 context: ExecutionContext,
574 ) -> Result<ExecutionResult, RuntimeError> {
575 self.0.fetch_add(1, Ordering::SeqCst);
576 tokio::time::sleep(std::time::Duration::from_millis(30)).await;
577 context.emit(RuntimeEvent::TextDelta {
578 delta: "persisted".into(),
579 })?;
580 Ok(ExecutionResult::new(
581 SessionId::new("resumed-session"),
582 request.prompt,
583 ))
584 }
585 }
586
587 async fn start_host(token: &str) -> (String, tokio::task::JoinHandle<()>) {
588 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
589 let address = listener.local_addr().unwrap();
590 let runtime = Runtime::builder().executor(Echo).build().unwrap();
591 let worker = LocalWorker::new(runtime, "worker-1", "Test worker");
592 let host = WorkerHost::new(worker).bearer_token(token);
593 let task = tokio::spawn(async move {
594 let _ = host.serve(listener).await;
595 });
596 (format!("ws://{address}"), task)
597 }
598
599 #[tokio::test]
600 async fn authenticated_websocket_worker_runs_and_reports_health() {
601 let (url, host) = start_host("secret").await;
602 let transport = WebSocketWorkerTransport::new(url).bearer_token("secret");
603 let worker = RemoteWorker::connect(transport).await.unwrap();
604 assert_eq!(worker.descriptor().name, "Test worker");
605
606 let health = worker.refresh_health().await.unwrap();
607 assert_eq!(health.status, WorkerHealthStatus::Ready);
608 let mut run = worker
609 .submit(AgentProfile::default(), RunRequest::new("hello remote"))
610 .await
611 .unwrap();
612 assert!(matches!(
613 run.next_event().await.unwrap().event,
614 RuntimeEvent::TextDelta { .. }
615 ));
616 assert!(matches!(
617 run.next_event().await.unwrap().event,
618 RuntimeEvent::FinalResponse { .. }
619 ));
620 assert_eq!(run.result().await.unwrap().final_text, "hello remote");
621 host.abort();
622 }
623
624 #[tokio::test]
625 async fn websocket_worker_rejects_invalid_credentials() {
626 let (url, host) = start_host("secret").await;
627 let transport = WebSocketWorkerTransport::new(url).bearer_token("wrong");
628 let error = match RemoteWorker::connect(transport).await {
629 Ok(_) => panic!("invalid Worker credentials should fail"),
630 Err(error) => error,
631 };
632 assert_eq!(error.code, RuntimeErrorCode::Unavailable);
633 host.abort();
634 }
635
636 #[tokio::test]
637 async fn websocket_worker_keeps_run_alive_and_resumes_after_disconnect() {
638 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
639 let address = listener.local_addr().unwrap();
640 let executions = Arc::new(AtomicUsize::new(0));
641 let runtime = Runtime::builder()
642 .executor(CountingEcho(executions.clone()))
643 .build()
644 .unwrap();
645 let host = WorkerHost::new(LocalWorker::new(runtime, "worker-1", "Test worker"));
646 let host_task = tokio::spawn(async move {
647 let _ = host.serve(listener).await;
648 });
649 let transport = WebSocketWorkerTransport::new(format!("ws://{address}"));
650 let run_id = microclaw_core::run_protocol::RunId::new("durable-run");
651 let mut first = transport.connect().await.unwrap();
652 let mut request = RunRequest::new("survive disconnect");
653 request.run_id = Some(run_id.clone());
654 first
655 .send(WorkerCommand::Submit {
656 protocol_version: WORKER_PROTOCOL_VERSION,
657 profile: AgentProfile::default(),
658 request: Box::new(request),
659 })
660 .await
661 .unwrap();
662 assert!(matches!(
663 first.receive().await.unwrap(),
664 Some(WorkerFrame::Accepted { .. })
665 ));
666 drop(first);
667
668 tokio::time::sleep(std::time::Duration::from_millis(60)).await;
669 let mut resumed = transport.connect().await.unwrap();
670 resumed
671 .send(WorkerCommand::ResumeEvents {
672 protocol_version: WORKER_PROTOCOL_VERSION,
673 run_id: run_id.clone(),
674 after_sequence: None,
675 })
676 .await
677 .unwrap();
678 let result = loop {
679 match resumed.receive().await.unwrap() {
680 Some(WorkerFrame::Event { .. }) => {}
681 Some(WorkerFrame::Result { result, .. }) => break result,
682 frame => panic!("unexpected resumed frame: {frame:?}"),
683 }
684 };
685 assert_eq!(result.run_id, run_id);
686 assert_eq!(executions.load(Ordering::SeqCst), 1);
687 host_task.abort();
688 }
689}