1use crate::security::{self, Security};
2use axum::{
3 extract::{DefaultBodyLimit, Multipart, Path, Query, State},
4 http::StatusCode,
5 middleware::from_fn_with_state,
6 response::{
7 sse::{Event, Sse},
8 IntoResponse,
9 },
10 routing::{get, post},
11 Json, Router,
12};
13use futures_util::{
14 stream::{self, Stream},
15 StreamExt,
16};
17use rpi_extcore::{
18 durable::{DurableMutation, QueueStatus},
19 host::BufferedEvent,
20 protocol::{self, ApiCommand},
21 reply::{IntoReply, ReplyKind, ServiceReply},
22 service::{
23 self, AuthRefreshRequest, BackendConfig, CreateProjectRequest, CreateSessionRequest,
24 FileQuery, ForkRequest, MetadataRequest, ProjectFoldersQuery, RenameProjectRequest,
25 RenameRequest, Service, SessionListQuery,
26 },
27};
28use serde::{de::DeserializeOwned, Deserialize};
29use serde_json::{json, Value};
30use std::{convert::Infallible, net::SocketAddr, path::PathBuf, sync::Arc};
31use tokio::sync::{broadcast, watch};
32#[derive(Clone, Debug, serde::Deserialize)]
33#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
34pub struct AppConfig {
35 #[serde(skip, default = "default_service_name")]
36 pub service_name: &'static str,
37 pub project: PathBuf,
38 pub projects: Vec<PathBuf>,
40 pub projects_root: Option<PathBuf>,
41 pub rpi_bin: PathBuf,
42 pub bind: String,
43 pub port: u16,
44 pub allowed_hosts: Option<Vec<String>>,
46 pub session_dir: Option<PathBuf>,
48 pub registry: PathBuf,
50}
51
52fn default_service_name() -> &'static str {
53 "rpi-http"
54}
55
56impl Default for AppConfig {
57 fn default() -> Self {
58 Self {
59 service_name: "rpi-http",
60 project: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
61 projects: Vec::new(),
62 projects_root: None,
63 rpi_bin: PathBuf::from("rpi"),
64 bind: "127.0.0.1".into(),
65 port: 3030,
66 allowed_hosts: None,
67 session_dir: None,
68 registry: rpi_extcore::registry::default_path(),
69 }
70 }
71}
72
73#[derive(Clone)]
74struct AppState {
75 backend: Arc<Service>,
76 security: Arc<Security>,
77 shutdown: watch::Receiver<bool>,
78 service_name: &'static str,
79}
80pub struct Listening {
81 pub address: SocketAddr,
82 pub hosts: Vec<String>,
83 pub password_enabled: bool,
84}
85struct HttpReply(ServiceReply);
86impl IntoResponse for HttpReply {
87 fn into_response(self) -> axum::response::Response {
88 let status = match self.0.kind {
89 ReplyKind::Success => StatusCode::OK,
90 ReplyKind::Created => StatusCode::CREATED,
91 ReplyKind::InvalidInput => StatusCode::BAD_REQUEST,
92 ReplyKind::NotFound => StatusCode::NOT_FOUND,
93 ReplyKind::AccessDenied => StatusCode::FORBIDDEN,
94 ReplyKind::LimitExceeded => StatusCode::PAYLOAD_TOO_LARGE,
95 ReplyKind::UnsupportedContent => StatusCode::UNSUPPORTED_MEDIA_TYPE,
96 ReplyKind::Internal => StatusCode::INTERNAL_SERVER_ERROR,
97 ReplyKind::HostUnavailable => StatusCode::BAD_GATEWAY,
98 ReplyKind::Timeout => StatusCode::GATEWAY_TIMEOUT,
99 ReplyKind::Unsupported => StatusCode::NOT_IMPLEMENTED,
100 ReplyKind::Conflict => StatusCode::CONFLICT,
101 ReplyKind::Empty => StatusCode::NO_CONTENT,
102 ReplyKind::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
103 };
104 if matches!(self.0.kind, ReplyKind::Empty) {
105 status.into_response()
106 } else {
107 (status, Json(self.0.value)).into_response()
108 }
109 }
110}
111pub async fn run(config: AppConfig) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
112 serve_until(
113 config,
114 Router::new(),
115 |info| println!("rpi HTTP API listening on {}", info.address),
116 async {
117 let _ = tokio::signal::ctrl_c().await;
118 },
119 )
120 .await
121}
122pub async fn serve_until(
123 config: AppConfig,
124 frontend: Router,
125 on_listen: impl FnOnce(Listening),
126 stop: impl std::future::Future<Output = ()> + Send + 'static,
127) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
128 let mut config = config;
129 let requested = SocketAddr::new(config.bind.parse()?, config.port);
130 let listener = tokio::net::TcpListener::bind(requested)
131 .await
132 .map_err(|error| {
133 std::io::Error::new(
134 error.kind(),
135 format!("cannot listen on {requested}: {error}"),
136 )
137 })?;
138 let address = listener.local_addr()?;
139 config.port = address.port();
140 let backend = Service::new(BackendConfig {
141 project: config.project.clone(),
142 projects: config.projects.clone(),
143 projects_root: config.projects_root.clone(),
144 rpi_bin: config.rpi_bin.clone(),
145 session_dir: config.session_dir.clone(),
146 registry: config.registry.clone(),
147 })?;
148 let hosts = allowed_hosts(&config);
149 let password = configured_password(&config);
150 let security = Arc::new(Security::new(hosts.clone(), password.as_deref()));
151 let (shutdown, receiver) = watch::channel(false);
152 let state = AppState {
153 backend,
154 security: security.clone(),
155 shutdown: receiver,
156 service_name: config.service_name,
157 };
158 let router = routes(state.clone())
159 .merge(frontend)
160 .layer(from_fn_with_state(security, crate::security::guard));
161 on_listen(Listening {
162 address,
163 hosts,
164 password_enabled: state.security.enabled(),
165 });
166 std::io::Write::flush(&mut std::io::stdout())?;
167 let mut shutdown_receiver = state.shutdown.clone();
168 let serving = std::future::IntoFuture::into_future(
169 axum::serve(listener, router).with_graceful_shutdown(async move {
170 if !*shutdown_receiver.borrow_and_update() {
171 let _ = shutdown_receiver.changed().await;
172 }
173 }),
174 );
175 tokio::pin!(serving);
176 tokio::select! {
177 result = &mut serving => result?,
178 _ = stop => {
179 println!(" Status Stopping...");
180 let _ = shutdown.send(true);
181 let _ = tokio::time::timeout(std::time::Duration::from_secs(8), async {
182 state.backend.shutdown().await;
183 let _ = serving.await;
184 }).await;
185 }
186 }
187 println!(" Status Stopped");
188 Ok(())
189}
190
191pub fn router(
194 backend: Arc<Service>,
195 config: &AppConfig,
196 shutdown: watch::Receiver<bool>,
197) -> Router {
198 let security = Arc::new(Security::new(
199 allowed_hosts(config),
200 configured_password(config).as_deref(),
201 ));
202 let state = AppState {
203 backend,
204 security: security.clone(),
205 shutdown,
206 service_name: config.service_name,
207 };
208 routes(state).layer(from_fn_with_state(security, crate::security::guard))
209}
210fn routes(state: AppState) -> Router {
211 Router::new()
212 .route("/api/v1/health", get(health))
213 .route("/api/v1/capabilities", get(capabilities))
214 .route("/api/v1/projects", get(list_projects).post(create_project))
215 .route("/api/v1/project-folders", get(list_project_folders))
216 .route("/api/v1/projects/{id}/rename", post(rename_project))
217 .route("/api/v1/projects/{id}/files", get(project_files))
218 .route(
219 "/api/v1/projects/{id}/upload",
220 post(upload_project_file).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
221 )
222 .route("/api/v1/projects/{id}/file", get(project_file))
223 .route("/api/v1/projects/{id}/git/status", get(project_status))
224 .route("/api/v1/projects/{id}/git/diff", get(project_diff))
225 .route(
226 "/api/v1/auth",
227 get(auth_status).post(auth_login).delete(auth_logout),
228 )
229 .route("/api/v1/auth/refresh", post(refresh_auth))
230 .route("/api/v1/sessions", post(create_session).get(list_sessions))
231 .route(
232 "/api/v1/sessions/{id}",
233 get(get_session).delete(delete_session),
234 )
235 .route("/api/v1/sessions/{id}/open", post(open_session))
236 .route("/api/v1/sessions/{id}/fork", post(fork_session))
237 .route("/api/v1/sessions/{id}/clone", post(clone_session))
238 .route("/api/v1/sessions/{id}/rename", post(rename_session))
239 .route("/api/v1/sessions/{id}/metadata", post(session_metadata))
240 .route("/api/v1/sessions/{id}/commands", post(command))
241 .route("/api/v1/sessions/{id}/snapshot", get(snapshot))
242 .route(
243 "/api/v1/sessions/{id}/durable",
244 get(durable_state).post(durable_mutation),
245 )
246 .route("/api/v1/sessions/{id}/durable/events", get(durable_events))
247 .route("/api/v1/sessions/{id}/events", get(events))
248 .with_state(state)
249}
250
251async fn upload_project_file(
252 State(state): State<AppState>,
253 Path(id): Path<String>,
254 mut multipart: Multipart,
255) -> HttpReply {
256 let Some(field) = (match multipart.next_field().await {
257 Ok(field) => field,
258 Err(error) => {
259 return HttpReply(
260 (
261 ReplyKind::InvalidInput,
262 json!({"error": format!("invalid upload: {error}")}),
263 )
264 .into_reply(),
265 );
266 }
267 }) else {
268 return HttpReply(
269 (ReplyKind::InvalidInput, json!({"error":"file is required"})).into_reply(),
270 );
271 };
272 let filename = field.file_name().unwrap_or("upload").to_string();
273 let bytes = match field.bytes().await {
274 Ok(bytes) => bytes.to_vec(),
275 Err(error) => {
276 return HttpReply(
277 (
278 ReplyKind::InvalidInput,
279 json!({"error": format!("cannot read upload: {error}")}),
280 )
281 .into_reply(),
282 );
283 }
284 };
285 HttpReply(
286 service::upload_project_file(&state.backend, id, filename, bytes)
287 .await
288 .into_reply(),
289 )
290}
291fn configured_password(config: &AppConfig) -> Option<String> {
292 let (primary, legacy) = if config.service_name == "rpi-web" {
293 ("RPI_WEB_PASSWORD", "RPI_HTTP_PASSWORD")
294 } else {
295 ("RPI_HTTP_PASSWORD", "RPI_WEB_PASSWORD")
296 };
297 std::env::var(primary)
298 .or_else(|_| std::env::var(legacy))
299 .ok()
300 .map(|value| value.trim().to_string())
301 .filter(|value| !value.is_empty())
302}
303
304fn allowed_hosts(config: &AppConfig) -> Vec<String> {
305 let (primary, legacy) = if config.service_name == "rpi-web" {
306 ("RPI_WEB_ALLOWED_HOSTS", "RPI_HTTP_ALLOWED_HOSTS")
307 } else {
308 ("RPI_HTTP_ALLOWED_HOSTS", "RPI_WEB_ALLOWED_HOSTS")
309 };
310 std::env::var(primary)
311 .or_else(|_| std::env::var(legacy))
312 .ok()
313 .map(|value| {
314 value
315 .split(',')
316 .map(str::trim)
317 .filter(|host| !host.is_empty())
318 .map(str::to_string)
319 .collect()
320 })
321 .filter(|hosts: &Vec<String>| !hosts.is_empty())
322 .or_else(|| config.allowed_hosts.clone())
323 .unwrap_or_else(|| {
324 let mut hosts = vec![
325 format!("127.0.0.1:{}", config.port),
326 format!("localhost:{}", config.port),
327 ];
328 if let Ok(ip) = config.bind.parse::<std::net::IpAddr>() {
329 if ip.is_ipv6() {
330 hosts.push(format!("[::1]:{}", config.port));
331 }
332 if !ip.is_unspecified() {
333 let authority = SocketAddr::new(ip, config.port).to_string();
334 if !hosts.contains(&authority) {
335 hosts.push(authority);
336 }
337 }
338 }
339 hosts
340 })
341}
342
343async fn auth_status(State(state): State<AppState>, headers: axum::http::HeaderMap) -> Json<Value> {
344 security::status_for(&state.security, &headers).await
345}
346
347async fn auth_login(
348 State(state): State<AppState>,
349 headers: axum::http::HeaderMap,
350 Json(request): Json<security::LoginRequest>,
351) -> impl IntoResponse {
352 security::login_for(&state.security, &headers, request).await
353}
354
355async fn auth_logout(
356 State(state): State<AppState>,
357 headers: axum::http::HeaderMap,
358) -> axum::response::Response {
359 security::logout_for(&state.security, &headers).await
360}
361
362async fn health(State(state): State<AppState>) -> Json<Value> {
363 Json(json!({"ok":true,"service":state.service_name,"protocol":"rpi"}))
364}
365async fn capabilities() -> Json<Value> {
366 Json(protocol::capabilities())
367}
368async fn list_projects(State(state): State<AppState>) -> axum::response::Response {
369 HttpReply(service::list_projects(&state.backend).await.into_reply()).into_response()
370}
371
372async fn list_project_folders(
373 State(state): State<AppState>,
374 Query(query): Query<ProjectFoldersQuery>,
375) -> axum::response::Response {
376 HttpReply(
377 service::list_project_folders(&state.backend, query)
378 .await
379 .into_reply(),
380 )
381 .into_response()
382}
383
384async fn create_project(
385 State(state): State<AppState>,
386 Json(request): Json<CreateProjectRequest>,
387) -> axum::response::Response {
388 HttpReply(
389 service::create_project(&state.backend, request)
390 .await
391 .into_reply(),
392 )
393 .into_response()
394}
395
396async fn rename_project(
397 State(state): State<AppState>,
398 Path(id): Path<String>,
399 Json(request): Json<RenameProjectRequest>,
400) -> axum::response::Response {
401 HttpReply(
402 service::rename_project(&state.backend, id, request)
403 .await
404 .into_reply(),
405 )
406 .into_response()
407}
408
409async fn project_files(
410 State(state): State<AppState>,
411 Path(id): Path<String>,
412 Query(query): Query<FileQuery>,
413) -> axum::response::Response {
414 HttpReply(
415 service::project_files(&state.backend, id, query)
416 .await
417 .into_reply(),
418 )
419 .into_response()
420}
421
422async fn project_file(
423 State(state): State<AppState>,
424 Path(id): Path<String>,
425 Query(query): Query<FileQuery>,
426) -> axum::response::Response {
427 HttpReply(
428 service::project_file(&state.backend, id, query)
429 .await
430 .into_reply(),
431 )
432 .into_response()
433}
434
435async fn project_status(
436 State(state): State<AppState>,
437 Path(id): Path<String>,
438) -> axum::response::Response {
439 HttpReply(
440 service::project_status(&state.backend, id)
441 .await
442 .into_reply(),
443 )
444 .into_response()
445}
446
447async fn project_diff(
448 State(state): State<AppState>,
449 Path(id): Path<String>,
450 Query(query): Query<FileQuery>,
451) -> axum::response::Response {
452 HttpReply(
453 service::project_diff(&state.backend, id, query)
454 .await
455 .into_reply(),
456 )
457 .into_response()
458}
459
460async fn refresh_auth(
461 State(state): State<AppState>,
462 Json(request): Json<AuthRefreshRequest>,
463) -> axum::response::Response {
464 HttpReply(
465 service::refresh_auth(&state.backend, request)
466 .await
467 .into_reply(),
468 )
469 .into_response()
470}
471
472async fn create_session(
473 State(state): State<AppState>,
474 body: axum::body::Bytes,
475) -> axum::response::Response {
476 let request: CreateSessionRequest = match optional_json(&body) {
477 Ok(request) => request,
478 Err(error) => return error.into_response(),
479 };
480 HttpReply(
481 service::create_session(&state.backend, request)
482 .await
483 .into_reply(),
484 )
485 .into_response()
486}
487
488async fn rename_session(
489 State(state): State<AppState>,
490 Path(id): Path<String>,
491 Json(request): Json<RenameRequest>,
492) -> axum::response::Response {
493 HttpReply(
494 service::rename_session(&state.backend, id, request)
495 .await
496 .into_reply(),
497 )
498 .into_response()
499}
500
501async fn session_metadata(
502 State(state): State<AppState>,
503 Path(id): Path<String>,
504 Json(request): Json<MetadataRequest>,
505) -> axum::response::Response {
506 HttpReply(
507 service::session_metadata(&state.backend, id, request)
508 .await
509 .into_reply(),
510 )
511 .into_response()
512}
513
514async fn fork_session(
515 State(state): State<AppState>,
516 Path(id): Path<String>,
517 Json(request): Json<ForkRequest>,
518) -> axum::response::Response {
519 HttpReply(
520 service::fork_session(&state.backend, id, request)
521 .await
522 .into_reply(),
523 )
524 .into_response()
525}
526
527async fn clone_session(
528 State(state): State<AppState>,
529 Path(id): Path<String>,
530 body: axum::body::Bytes,
531) -> axum::response::Response {
532 let request: ForkRequest = match optional_json(&body) {
533 Ok(request) => request,
534 Err(error) => return error.into_response(),
535 };
536 HttpReply(
537 service::clone_session(&state.backend, id, request)
538 .await
539 .into_reply(),
540 )
541 .into_response()
542}
543
544async fn open_session(
545 State(state): State<AppState>,
546 Path(id): Path<String>,
547) -> axum::response::Response {
548 HttpReply(service::open_session(&state.backend, id).await.into_reply()).into_response()
549}
550
551async fn list_sessions(
552 State(state): State<AppState>,
553 Query(query): Query<SessionListQuery>,
554) -> axum::response::Response {
555 HttpReply(
556 service::list_sessions(&state.backend, query)
557 .await
558 .into_reply(),
559 )
560 .into_response()
561}
562
563async fn get_session(
564 State(state): State<AppState>,
565 Path(id): Path<String>,
566) -> axum::response::Response {
567 HttpReply(service::get_session(&state.backend, id).await.into_reply()).into_response()
568}
569
570async fn delete_session(
571 State(state): State<AppState>,
572 Path(id): Path<String>,
573) -> axum::response::Response {
574 HttpReply(
575 service::delete_session(&state.backend, id)
576 .await
577 .into_reply(),
578 )
579 .into_response()
580}
581
582async fn command(
583 State(state): State<AppState>,
584 Path(id): Path<String>,
585 Json(command): Json<ApiCommand>,
586) -> axum::response::Response {
587 HttpReply(
588 service::command(&state.backend, id, command)
589 .await
590 .into_reply(),
591 )
592 .into_response()
593}
594
595#[derive(Debug, Deserialize)]
596#[serde(rename_all = "camelCase", deny_unknown_fields)]
597struct DurableMutationRequest {
598 operation: String,
599 #[serde(default)]
600 text: Option<String>,
601 #[serde(default)]
602 item_id: Option<String>,
603 #[serde(default)]
604 status: Option<String>,
605 #[serde(default)]
606 note: Option<String>,
607}
608
609fn parse_durable_mutation(request: DurableMutationRequest) -> Result<DurableMutation, String> {
610 let item_id = || {
611 request
612 .item_id
613 .clone()
614 .filter(|value| !value.trim().is_empty())
615 .ok_or_else(|| "itemId is required".to_string())
616 };
617 match request.operation.trim() {
618 "start_run" => Ok(DurableMutation::StartRun),
619 "settle_run" => Ok(DurableMutation::SettleRun),
620 "recover_run" => Ok(DurableMutation::RecoverRun),
621 "enqueue" => Ok(DurableMutation::Enqueue {
622 text: request
623 .text
624 .filter(|value| !value.trim().is_empty())
625 .ok_or_else(|| "text is required".to_string())?,
626 }),
627 "claim" => Ok(DurableMutation::Claim { id: item_id()? }),
628 "finish" => {
629 let status = match request.status.as_deref() {
630 Some("completed") => QueueStatus::Completed,
631 Some("failed") => QueueStatus::Failed,
632 Some(other) => return Err(format!("invalid finish status: {other}")),
633 None => return Err("status is required for finish".to_string()),
634 };
635 Ok(DurableMutation::Finish {
636 id: item_id()?,
637 status,
638 note: request.note.unwrap_or_default(),
639 })
640 }
641 "cancel" => Ok(DurableMutation::Cancel {
642 id: item_id()?,
643 note: request.note.unwrap_or_default(),
644 }),
645 other => Err(format!("unsupported durable operation: {other}")),
646 }
647}
648
649async fn durable_state(
650 State(state): State<AppState>,
651 Path(id): Path<String>,
652) -> axum::response::Response {
653 HttpReply(
654 service::durable_state(&state.backend, id)
655 .await
656 .into_reply(),
657 )
658 .into_response()
659}
660
661async fn durable_mutation(
662 State(state): State<AppState>,
663 Path(id): Path<String>,
664 Json(request): Json<DurableMutationRequest>,
665) -> axum::response::Response {
666 let mutation = match parse_durable_mutation(request) {
667 Ok(mutation) => mutation,
668 Err(error) => {
669 return (StatusCode::BAD_REQUEST, Json(json!({"error": error}))).into_response()
670 }
671 };
672 HttpReply(
673 service::mutate_durable(&state.backend, id, mutation)
674 .await
675 .into_reply(),
676 )
677 .into_response()
678}
679
680async fn snapshot(
681 State(state): State<AppState>,
682 Path(id): Path<String>,
683 Query(options): Query<service::SnapshotOptions>,
684) -> axum::response::Response {
685 HttpReply(
686 service::snapshot_with_options(&state.backend, id, options)
687 .await
688 .into_reply(),
689 )
690 .into_response()
691}
692
693fn optional_json<T: DeserializeOwned + Default>(
696 body: &[u8],
697) -> Result<T, (StatusCode, Json<Value>)> {
698 if body.is_empty() {
699 return Ok(T::default());
700 }
701 serde_json::from_slice(body).map_err(|_| {
702 (
703 StatusCode::BAD_REQUEST,
704 Json(json!({"error":"invalid JSON request body"})),
705 )
706 })
707}
708
709#[derive(Debug, serde::Deserialize)]
710struct EventQuery {
711 after: Option<u64>,
712}
713
714async fn durable_events(
715 State(state): State<AppState>,
716 Path(id): Path<String>,
717 headers: axum::http::HeaderMap,
718 Query(query): Query<EventQuery>,
719) -> axum::response::Response {
720 let after = headers
721 .get("last-event-id")
722 .and_then(|value| value.to_str().ok())
723 .and_then(|value| value.parse::<u64>().ok())
724 .or(query.after);
725 let Some(subscription) = state.backend.durable_subscription(&id, after).await else {
726 let status = if state.backend.live_session(&id).await.is_some() {
727 StatusCode::SERVICE_UNAVAILABLE
728 } else {
729 StatusCode::NOT_FOUND
730 };
731 return (
732 status,
733 Json(json!({
734 "error": if status == StatusCode::NOT_FOUND {
735 "session not found"
736 } else {
737 "durable unavailable"
738 },
739 "code": if status == StatusCode::NOT_FOUND {
740 "session_not_found"
741 } else {
742 "durable_unavailable"
743 }
744 })),
745 )
746 .into_response();
747 };
748 let mut shutdown = state.shutdown;
749 Sse::new(
750 authorized_events(
751 durable_event_stream(id, subscription),
752 state.security,
753 headers,
754 )
755 .take_until(async move {
756 if !*shutdown.borrow_and_update() {
757 let _ = shutdown.changed().await;
758 }
759 }),
760 )
761 .keep_alive(axum::response::sse::KeepAlive::default())
762 .into_response()
763}
764
765fn durable_event_stream(
766 session_id: String,
767 subscription: rpi_extcore::durable::DurableSubscription,
768) -> impl Stream<Item = Result<Event, Infallible>> {
769 let rpi_extcore::durable::DurableSubscription {
770 ready,
771 replay,
772 receiver,
773 gap,
774 after,
775 } = subscription;
776 let mut head = Vec::new();
777 if after.is_none() {
778 head.push(Ok(Event::default()
779 .event("durable_ready")
780 .id(ready.revision.to_string())
781 .data(
782 json!({
783 "sessionId": session_id,
784 "revision": ready.revision,
785 "state": ready,
786 })
787 .to_string(),
788 )));
789 }
790 if gap {
791 head.push(Ok(Event::default().event("resync_required").data(
792 json!({
793 "type": "resync_required",
794 "code": "durable_event_gap"
795 })
796 .to_string(),
797 )));
798 }
799 stream::iter(head).chain(
800 rpi_extcore::durable::replay_changes(replay, receiver, after.or(Some(ready.revision))).map(
801 move |item| {
802 Ok(match item {
803 rpi_extcore::durable::DurableStreamItem::Change(change) => Event::default()
804 .event("durable_change")
805 .id(change.revision.to_string())
806 .data(
807 json!({
808 "sessionId": session_id,
809 "revision": change.revision,
810 "state": change.state,
811 })
812 .to_string(),
813 ),
814 rpi_extcore::durable::DurableStreamItem::ResyncRequired { dropped } => {
815 Event::default().event("resync_required").data(
816 json!({
817 "type": "resync_required",
818 "code": "durable_event_stream_lagged",
819 "dropped": dropped
820 })
821 .to_string(),
822 )
823 }
824 })
825 },
826 ),
827 )
828}
829
830async fn events(
831 State(state): State<AppState>,
832 Path(id): Path<String>,
833 headers: axum::http::HeaderMap,
834 Query(query): Query<EventQuery>,
835) -> axum::response::Response {
836 let after = headers
837 .get("last-event-id")
838 .and_then(|value| value.to_str().ok())
839 .and_then(|value| value.parse::<u64>().ok())
840 .or(query.after);
841 let Some(subscription) = state.backend.subscribe_events(&id, after).await else {
842 return (
843 StatusCode::NOT_FOUND,
844 Json(json!({"error":"session not found"})),
845 )
846 .into_response();
847 };
848 let mut shutdown = state.shutdown;
849 Sse::new(
850 authorized_events(
851 event_stream(
852 subscription.ready,
853 subscription.replay,
854 subscription.receiver,
855 subscription.gap,
856 subscription.after,
857 ),
858 state.security,
859 headers,
860 )
861 .take_until(async move {
862 if !*shutdown.borrow_and_update() {
863 let _ = shutdown.changed().await;
864 }
865 }),
866 )
867 .keep_alive(axum::response::sse::KeepAlive::default())
868 .into_response()
869}
870
871fn event_stream(
872 ready: Value,
873 replay: Vec<BufferedEvent>,
874 receiver: broadcast::Receiver<BufferedEvent>,
875 gap: bool,
876 after: Option<u64>,
877) -> impl Stream<Item = Result<Event, Infallible>> {
878 let head: Vec<Result<Event, Infallible>> =
879 std::iter::once(Ok(Event::default().event("ready").data(ready.to_string())))
880 .chain(gap.then(|| {
881 Ok(Event::default()
882 .event("resync_required")
883 .data("{\"type\":\"resync_required\"}"))
884 }))
885 .collect();
886 stream::iter(head).chain(rpi_extcore::events::replay_stream(replay,receiver,after).map(|item| {
887 Ok(match item {
888 rpi_extcore::events::StreamItem::Event(event)=>to_sse(event),
889 rpi_extcore::events::StreamItem::ResyncRequired{dropped}=>Event::default().event("resync_required").data(json!({"type":"resync_required","code":"event_stream_lagged","dropped":dropped}).to_string()),
890 })
891 }))
892}
893
894fn authorized_events(
897 events: impl Stream<Item = Result<Event, Infallible>> + Send + 'static,
898 security: Arc<Security>,
899 headers: axum::http::HeaderMap,
900) -> impl Stream<Item = Result<Event, Infallible>> {
901 let expiry_check = tokio::time::interval(std::time::Duration::from_secs(10));
902 stream::unfold(
903 (Box::pin(events), expiry_check),
904 move |(mut events, mut expiry_check)| {
905 let security = security.clone();
906 let headers = headers.clone();
907 async move {
908 loop {
909 tokio::select! {
910 event = events.next() => {
911 let event = event?;
912 if !security.authorized(&headers).await { return None; }
913 return Some((event, (events, expiry_check)));
914 }
915 _ = expiry_check.tick() => {
916 if !security.authorized(&headers).await { return None; }
917 }
918 }
919 }
920 }
921 },
922 )
923}
924
925fn to_sse(mut event: BufferedEvent) -> Event {
926 rpi_extcore::timing::refresh_event(&mut event.value);
927 let event_type = event
928 .value
929 .get("type")
930 .and_then(Value::as_str)
931 .unwrap_or("message");
932 Event::default()
933 .event(event_type)
934 .id(event.seq.to_string())
935 .data(event.value.to_string())
936}
937
938#[cfg(test)]
939mod tests {
940 use super::*;
941
942 #[test]
943 fn durable_requests_map_to_protocol_neutral_mutations() {
944 let mutation = parse_durable_mutation(DurableMutationRequest {
945 operation: "enqueue".into(),
946 text: Some("run tests".into()),
947 item_id: None,
948 status: None,
949 note: None,
950 })
951 .unwrap();
952 assert!(matches!(mutation, DurableMutation::Enqueue { text } if text == "run tests"));
953
954 let mutation = parse_durable_mutation(DurableMutationRequest {
955 operation: "finish".into(),
956 text: None,
957 item_id: Some("item-1".into()),
958 status: Some("completed".into()),
959 note: Some("passed".into()),
960 })
961 .unwrap();
962 assert!(matches!(
963 mutation,
964 DurableMutation::Finish {
965 id,
966 status: QueueStatus::Completed,
967 note
968 } if id == "item-1" && note == "passed"
969 ));
970 }
971
972 #[test]
973 fn durable_requests_reject_invalid_operations_and_statuses() {
974 let error = parse_durable_mutation(DurableMutationRequest {
975 operation: "finish".into(),
976 text: None,
977 item_id: Some("item-1".into()),
978 status: Some("queued".into()),
979 note: None,
980 })
981 .unwrap_err();
982 assert!(error.contains("invalid finish status"));
983 }
984
985 #[test]
986 fn optional_creation_bodies_reject_invalid_json_and_types() {
987 assert!(optional_json::<CreateSessionRequest>(b"").is_ok());
988 assert!(optional_json::<CreateSessionRequest>(br#"{"projectId":"project-a"}"#).is_ok());
989 for body in [b"{".as_slice(), b"null", br#"{"projectId":42}"#] {
990 assert_eq!(
991 optional_json::<CreateSessionRequest>(body).unwrap_err().0,
992 StatusCode::BAD_REQUEST
993 );
994 }
995 assert!(optional_json::<ForkRequest>(b"").is_ok());
996 assert!(optional_json::<ForkRequest>(br#"{"entryId":42}"#).is_err());
997 }
998
999 #[tokio::test]
1000 async fn live_events_do_not_duplicate_replay_or_the_snapshot_cursor() {
1001 for replay in [
1002 vec![],
1003 vec![BufferedEvent {
1004 seq: 5,
1005 value: json!({"type":"agent_start"}),
1006 }],
1007 ] {
1008 let (sender, receiver) = broadcast::channel(8);
1009 for seq in [4, 5, 6] {
1010 sender
1011 .send(BufferedEvent {
1012 seq,
1013 value: json!({"type":"agent_start"}),
1014 })
1015 .unwrap();
1016 }
1017 drop(sender);
1018 let has_replay = !replay.is_empty();
1019 let stream = event_stream(json!({"type":"ready"}), replay, receiver, false, Some(5));
1020 let body = axum::body::to_bytes(Sse::new(stream).into_response().into_body(), 4096)
1021 .await
1022 .unwrap();
1023 let text = String::from_utf8(body.to_vec()).unwrap();
1024 assert!(!text.contains("id: 4\n"));
1025 assert_eq!(text.matches("id: 5\n").count(), usize::from(has_replay));
1026 assert_eq!(text.matches("id: 6\n").count(), 1);
1027 }
1028 }
1029
1030 #[tokio::test]
1031 async fn durable_events_emit_ready_changes_and_reconnect_replay() {
1032 use rpi_extcore::durable::{DurableChange, DurableState, DurableSubscription};
1033
1034 let (sender, receiver) = broadcast::channel(8);
1035 sender
1036 .send(DurableChange {
1037 revision: 1,
1038 state: DurableState {
1039 revision: 1,
1040 ..DurableState::default()
1041 },
1042 })
1043 .unwrap();
1044 drop(sender);
1045 let body = axum::body::to_bytes(
1046 Sse::new(durable_event_stream(
1047 "session-1".into(),
1048 DurableSubscription {
1049 ready: DurableState::default(),
1050 replay: vec![],
1051 receiver,
1052 gap: false,
1053 after: None,
1054 },
1055 ))
1056 .into_response()
1057 .into_body(),
1058 4096,
1059 )
1060 .await
1061 .unwrap();
1062 let text = String::from_utf8(body.to_vec()).unwrap();
1063 assert!(text.contains("event: durable_ready\n"));
1064 assert!(text.contains("event: durable_change\n"));
1065 assert!(text.contains("id: 1\n"));
1066
1067 let (sender, receiver) = broadcast::channel(8);
1068 drop(sender);
1069 let body = axum::body::to_bytes(
1070 Sse::new(durable_event_stream(
1071 "session-1".into(),
1072 DurableSubscription {
1073 ready: DurableState {
1074 revision: 1,
1075 ..DurableState::default()
1076 },
1077 replay: vec![DurableChange {
1078 revision: 1,
1079 state: DurableState {
1080 revision: 1,
1081 ..DurableState::default()
1082 },
1083 }],
1084 receiver,
1085 gap: false,
1086 after: Some(0),
1087 },
1088 ))
1089 .into_response()
1090 .into_body(),
1091 4096,
1092 )
1093 .await
1094 .unwrap();
1095 let text = String::from_utf8(body.to_vec()).unwrap();
1096 assert!(!text.contains("event: durable_ready\n"));
1097 assert_eq!(text.matches("id: 1\n").count(), 1);
1098 }
1099
1100 #[tokio::test]
1101 async fn logout_revokes_an_already_open_event_stream() {
1102 let security = Arc::new(Security::new(["example.test".into()], Some("secret")));
1103 let token = security.login("secret").await.unwrap();
1104 let mut headers = axum::http::HeaderMap::new();
1105 headers.insert(
1106 axum::http::header::COOKIE,
1107 format!("rpi_web_session={token}").parse().unwrap(),
1108 );
1109 let (sender, receiver) = broadcast::channel(8);
1110 let stream = authorized_events(
1111 event_stream(json!({"type":"ready"}), vec![], receiver, false, None),
1112 security.clone(),
1113 headers.clone(),
1114 );
1115 futures_util::pin_mut!(stream);
1116 assert!(stream.next().await.is_some());
1117 security.logout(&headers).await;
1118 sender
1119 .send(BufferedEvent {
1120 seq: 1,
1121 value: json!({"type":"message_end","message":"private"}),
1122 })
1123 .unwrap();
1124 assert!(stream.next().await.is_none());
1125 }
1126}