1use async_stream::stream;
4use axum::extract::{DefaultBodyLimit, Multipart, Path as AxumPath, Query, State};
5use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header};
6use axum::response::sse::{Event, KeepAlive, Sse};
7use axum::response::{IntoResponse, Redirect};
8use axum::routing::{get, post};
9use axum::{Json, Router};
10use base64::Engine;
11use futures::future::join_all;
12use meerkat_core::ContentInput;
13use meerkat_core::comms::TrustedPeerDescriptor;
14use meerkat_mob::MobState;
15use meerkat_mob::ids::{MeerkatId, MobId};
16use meerkat_mob::launch::MemberLaunchMode;
17use meerkat_mob::runtime::reconcile::MemberFilter;
18use meerkat_mob::{MobHandle, PeerTarget, ProfileName, SpawnMemberSpec};
19
20use crate::mob_handle_runtime::{
21 member_entry_to_json, model_capabilities_for_member_entry, model_capabilities_for_role,
22};
23use serde_json::{Value, json};
24use std::collections::{BTreeMap, BTreeSet};
25use std::convert::Infallible;
26use std::sync::Arc;
27use std::time::{Duration, Instant};
28
29use crate::blob_store::{BinaryBlobPayload, BinaryBlobStore, is_valid_blob_id_value};
30use crate::console_aggregator::{
31 ConsoleCursor, ConsoleFrame, ConsoleIdentityRecord, ConsoleLogError, ConsoleLogResult,
32 ConsoleLogStore, ConsoleReplayUnavailable, ConsoleSendError, ConsoleSendRequest,
33 ConsoleTimelineEvent, ConsoleTimelineMode, ConsoleTimelineQuery, ConsoleTimelineWindowQuery,
34 ConsoleVisibility, ConsoleVisibilityPolicy, HideImplicitDelegateMembersConsoleVisibilityPolicy,
35 MobKitConsoleAggregator,
36};
37use crate::contact_directory::ContactDirectory;
38use crate::http_sse::{DEFAULT_KEEP_ALIVE_INTERVAL, KEEP_ALIVE_TEXT};
39use crate::mob_handle_runtime::{MEMBER_STATE_ACTIVE, MEMBER_STATE_RETIRING, MobRuntime};
40use crate::rpc::{JSONRPC_VERSION, JsonRpcError, JsonRpcRequest, JsonRpcResponse};
41use crate::runtime::MobkitRuntimeHandle;
42use crate::runtime::{
43 ConsoleAgentLiveSnapshot, ConsoleLiveSnapshot, ConsoleMember, ConsoleModelCapabilities,
44 ConsoleRestJsonRequest, DeliveryHistoryRequest, GatingDecideRequest, GatingDecision,
45 RuntimeDecisionState, extract_bearer_token_from_header,
46 handle_console_rest_json_route_with_snapshot, validate_console_token,
47};
48use crate::runtime::{MetadataScope, RuntimeMetadataTable, labels_to_json_value};
49use crate::unified_runtime::console_events::ConsoleEventStore;
50use crate::unified_runtime::mob_events::MobEventsStore;
51use crate::unified_runtime::{EventLogStore, EventQuery};
52
53#[derive(Clone)]
54pub struct ConsoleJsonState {
55 pub decisions: RuntimeDecisionState,
56 pub runtime: Option<MobRuntime>,
57 pub module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
58 pub contact_directory: Option<ContactDirectory>,
59 pub event_log: Option<std::sync::Arc<dyn EventLogStore>>,
60 pub gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
64 pub(crate) identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
65 pub(crate) console_events: Option<ConsoleEventStore>,
66 pub(crate) console_aggregator: Option<MobKitConsoleAggregator>,
67 pub(crate) mob_events: Option<MobEventsStore>,
68 pub(crate) metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
69 pub(crate) visibility_policy: Arc<dyn ConsoleVisibilityPolicy>,
70 pub(crate) snapshot_read_model: ConsoleSnapshotReadModel,
71}
72
73#[derive(Clone, Default)]
74pub(crate) struct ConsoleSnapshotReadModel {
75 inner: Arc<tokio::sync::RwLock<ConsoleSnapshotReadModelState>>,
76 refresh_lock: Arc<tokio::sync::Mutex<()>>,
82 primed: Arc<std::sync::atomic::AtomicBool>,
86}
87
88#[derive(Clone, Default)]
89struct ConsoleSnapshotReadModelState {
90 running: Option<bool>,
91 session_id_by_identity: BTreeMap<String, String>,
92 session_owner_by_id: BTreeMap<String, String>,
93 primary_members: Vec<ConsoleMember>,
98 delegate_member_groups: Vec<Vec<ConsoleMember>>,
103}
104
105impl ConsoleSnapshotReadModel {
106 async fn snapshot(&self, runtime: &MobRuntime) -> ConsoleSnapshotReadModelState {
113 if !self.primed.load(std::sync::atomic::Ordering::Acquire) {
114 self.prime_now(runtime).await;
115 }
116 self.inner.read().await.clone()
117 }
118
119 async fn prime_now(&self, runtime: &MobRuntime) {
139 if self.primed.load(std::sync::atomic::Ordering::Acquire) {
140 return;
141 }
142 let _guard = self.refresh_lock.clone().lock_owned().await;
143 if self.primed.load(std::sync::atomic::Ordering::Acquire) {
144 return;
145 }
146 let refreshed = collect_console_snapshot_read_model(runtime).await;
147 *self.inner.write().await = refreshed;
148 self.primed
149 .store(true, std::sync::atomic::Ordering::Release);
150 }
154
155 fn refresh_soon(&self, runtime: MobRuntime) {
162 let Ok(runtime_handle) = tokio::runtime::Handle::try_current() else {
163 return;
164 };
165 let Ok(guard) = self.refresh_lock.clone().try_lock_owned() else {
166 return;
167 };
168 let inner = Arc::clone(&self.inner);
169 let primed = Arc::clone(&self.primed);
170 runtime_handle.spawn(async move {
171 let _guard = guard;
172 let refreshed = collect_console_snapshot_read_model(&runtime).await;
173 *inner.write().await = refreshed;
174 primed.store(true, std::sync::atomic::Ordering::Release);
175 });
179 }
180}
181
182const CONSOLE_FRONTEND_INDEX_HTML: &str = include_str!("../console-dist/index.html");
183const CONSOLE_FRONTEND_APP_JS: &str = include_str!("../console-dist/console-app.js");
184const CONSOLE_FRONTEND_APP_CSS: &str = include_str!("../console-dist/console-app.css");
185const MAX_MULTIPART_IMAGE_BYTES: usize = 25 * 1024 * 1024;
186const MAX_MULTIPART_IMAGES: usize = 4;
187const MAX_MULTIPART_BODY_BYTES: usize =
188 (MAX_MULTIPART_IMAGE_BYTES * MAX_MULTIPART_IMAGES) + 1024 * 1024;
189
190pub fn console_json_router(decisions: RuntimeDecisionState) -> Router {
191 console_json_router_with_state(ConsoleJsonState {
192 decisions,
193 runtime: None,
194 module_runtime: None,
195 contact_directory: None,
196 event_log: None,
197 gateway_peer_keys: None,
198 identity_runtime: None,
199 console_events: None,
200 console_aggregator: None,
201 mob_events: None,
202 metadata_table: None,
203 visibility_policy: Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
204 snapshot_read_model: ConsoleSnapshotReadModel::default(),
205 })
206}
207
208pub fn console_json_router_with_aggregator(
209 decisions: RuntimeDecisionState,
210 console_aggregator: MobKitConsoleAggregator,
211) -> Router {
212 console_json_router_with_state(ConsoleJsonState {
213 decisions,
214 runtime: None,
215 module_runtime: None,
216 contact_directory: None,
217 event_log: None,
218 gateway_peer_keys: None,
219 identity_runtime: None,
220 console_events: None,
221 console_aggregator: Some(console_aggregator),
222 mob_events: None,
223 metadata_table: None,
224 visibility_policy: Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
225 snapshot_read_model: ConsoleSnapshotReadModel::default(),
226 })
227}
228
229pub fn console_json_router_with_runtime(
230 decisions: RuntimeDecisionState,
231 runtime: MobRuntime,
232 contact_directory: Option<ContactDirectory>,
233 event_log: Option<std::sync::Arc<dyn EventLogStore>>,
234) -> Router {
235 console_json_router_with_runtime_and_events(
236 decisions,
237 runtime,
238 None,
239 contact_directory,
240 event_log,
241 None,
242 None,
243 None,
244 None,
245 None,
246 None,
247 )
248}
249
250#[allow(clippy::too_many_arguments)]
251pub(crate) fn console_json_router_with_runtime_and_events(
252 decisions: RuntimeDecisionState,
253 runtime: MobRuntime,
254 module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
255 contact_directory: Option<ContactDirectory>,
256 event_log: Option<std::sync::Arc<dyn EventLogStore>>,
257 gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
258 console_events: Option<ConsoleEventStore>,
259 console_log_store: Option<std::sync::Arc<dyn ConsoleLogStore>>,
260 mob_events: Option<MobEventsStore>,
261 metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
262 identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
263) -> Router {
264 console_json_router_with_runtime_events_and_policy(
265 decisions,
266 runtime,
267 module_runtime,
268 contact_directory,
269 event_log,
270 gateway_peer_keys,
271 console_events,
272 console_log_store,
273 mob_events,
274 metadata_table,
275 identity_runtime,
276 Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
277 )
278}
279
280#[allow(clippy::too_many_arguments)]
281pub(crate) fn console_json_router_with_runtime_events_and_policy(
282 decisions: RuntimeDecisionState,
283 runtime: MobRuntime,
284 module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
285 contact_directory: Option<ContactDirectory>,
286 event_log: Option<std::sync::Arc<dyn EventLogStore>>,
287 gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
288 console_events: Option<ConsoleEventStore>,
289 console_log_store: Option<std::sync::Arc<dyn ConsoleLogStore>>,
290 mob_events: Option<MobEventsStore>,
291 metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
292 identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
293 visibility_policy: Arc<dyn ConsoleVisibilityPolicy>,
294) -> Router {
295 let console_aggregator = console_events.clone().map(|events| {
296 if let Some(store) = console_log_store {
297 let aggregator = MobKitConsoleAggregator::new(store);
298 aggregator.register_runtime_handles_with_policy(
299 "default",
300 "",
301 runtime.clone(),
302 identity_runtime.clone(),
303 events,
304 visibility_policy.clone(),
305 );
306 aggregator
307 } else {
308 let aggregator = MobKitConsoleAggregator::in_memory();
309 aggregator.register_runtime_handles_with_policy(
310 "default",
311 "",
312 runtime.clone(),
313 identity_runtime.clone(),
314 events,
315 visibility_policy.clone(),
316 );
317 aggregator
318 }
319 });
320 let snapshot_read_model = ConsoleSnapshotReadModel::default();
321 snapshot_read_model.refresh_soon(runtime.clone());
322 console_json_router_with_state(ConsoleJsonState {
323 decisions,
324 runtime: Some(runtime),
325 module_runtime,
326 contact_directory,
327 event_log,
328 gateway_peer_keys,
329 identity_runtime,
330 console_events,
331 console_aggregator,
332 mob_events,
333 metadata_table,
334 visibility_policy,
335 snapshot_read_model,
336 })
337}
338
339pub fn console_frontend_router() -> Router {
340 Router::new()
341 .route("/", get(|| async { Redirect::temporary("/console") }))
342 .route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT }))
343 .route("/console", get(console_frontend_index_handler))
344 .route("/console/", get(console_frontend_index_handler))
345 .route(
346 "/console/assets/console-app.js",
347 get(console_frontend_app_js_handler),
348 )
349 .route(
350 "/console/assets/console-app.css",
351 get(console_frontend_app_css_handler),
352 )
353}
354
355fn console_json_router_with_state(state: ConsoleJsonState) -> Router {
356 let router = Router::new()
357 .route("/console/experience", get(console_json_handler))
358 .route("/console/modules", get(console_json_handler))
359 .route("/console/identities", get(console_identities_handler))
360 .route("/console/timeline", get(console_timeline_handler))
361 .route(
362 "/console/timeline/stream",
363 get(console_timeline_stream_handler),
364 )
365 .route(
366 "/console/identity/{identity}/stream",
367 get(console_identity_timeline_stream_handler),
368 )
369 .route("/console/send", post(console_send_handler))
370 .route("/console/rpc", post(console_rpc_handler))
371 .route(
372 "/console/rpc/multipart",
373 post(console_rpc_multipart_handler)
374 .layer(DefaultBodyLimit::max(MAX_MULTIPART_BODY_BYTES)),
375 )
376 .route("/blobs/{blob_id}", get(blob_get_handler));
377 router.with_state(state)
378}
379
380pub async fn console_json_handler(
381 State(state): State<ConsoleJsonState>,
382 headers: HeaderMap,
383 uri: Uri,
384) -> impl IntoResponse {
385 let mut path = uri
386 .path_and_query()
387 .map(|path_and_query| path_and_query.as_str().to_string())
388 .unwrap_or_else(|| uri.path().to_string());
389
390 let already_has_token = path
402 .split_once('?')
403 .map(|(_, q)| form_urlencoded::parse(q.as_bytes()).any(|(key, _)| key == "auth_token"))
404 .unwrap_or(false);
405 if !already_has_token
406 && let Some(bearer) = headers
407 .get(header::AUTHORIZATION)
408 .and_then(|v| v.to_str().ok())
409 .and_then(extract_bearer_token_from_header)
410 {
411 let encoded: String = form_urlencoded::byte_serialize(bearer.as_bytes()).collect();
412 let sep = if path.contains('?') { '&' } else { '?' };
413 path = format!("{path}{sep}auth_token={encoded}");
414 }
415
416 let config_module_ids: Vec<String> = state
417 .decisions
418 .modules
419 .iter()
420 .map(|m| m.id.clone())
421 .collect();
422 let live_snapshot = match &state.runtime {
423 Some(runtime) => {
424 state.snapshot_read_model.refresh_soon(runtime.clone());
425 Some(
426 build_live_snapshot(
427 runtime,
428 &config_module_ids,
429 state.console_events.as_ref(),
430 state.visibility_policy.as_ref(),
431 &state.snapshot_read_model,
432 )
433 .await,
434 )
435 }
436 None => match &state.console_aggregator {
437 Some(aggregator) => build_aggregator_live_snapshot(aggregator, &config_module_ids)
438 .await
439 .ok(),
440 None => None,
441 },
442 }
443 .map(|mut snapshot| {
444 apply_console_visibility_policy(&mut snapshot, state.visibility_policy.as_ref());
445 snapshot
446 });
447
448 let response = handle_console_rest_json_route_with_snapshot(
449 &state.decisions,
450 &ConsoleRestJsonRequest {
451 method: "GET".to_string(),
452 path,
453 auth: None,
454 },
455 live_snapshot.as_ref(),
456 );
457 let status = StatusCode::from_u16(response.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
458 (status, Json::<Value>(response.body))
459}
460
461pub async fn console_rpc_handler(
462 State(state): State<ConsoleJsonState>,
463 headers: HeaderMap,
464 uri: Uri,
465 Json(request): Json<Value>,
466) -> impl IntoResponse {
467 let parsed_request = match serde_json::from_value::<JsonRpcRequest>(request) {
469 Ok(req) => req,
470 Err(_) => {
471 return (
472 StatusCode::OK,
473 Json::<Value>(serde_json::json!({
474 "jsonrpc": JSONRPC_VERSION,
475 "id": Value::Null,
476 "error": { "code": -32600, "message": "Invalid Request" }
477 })),
478 );
479 }
480 };
481
482 if !console_request_authorized(&state, &headers, &uri) {
487 return (
488 StatusCode::UNAUTHORIZED,
489 Json::<Value>(serde_json::json!({
490 "jsonrpc": JSONRPC_VERSION,
491 "id": parsed_request.id.unwrap_or(Value::Null),
492 "error": {
493 "code": -32600,
494 "message": "unauthorized: console rpc requires a valid auth token",
495 }
496 })),
497 );
498 }
499 let is_authenticated = true;
508 let Some(runtime) = &state.runtime else {
509 let response_value = Box::pin(handle_console_aggregator_rpc(
510 state.console_aggregator.clone(),
511 parsed_request,
512 is_authenticated,
513 ))
514 .await;
515 return (StatusCode::OK, Json::<Value>(response_value));
516 };
517
518 let response_value = Box::pin(handle_console_runtime_rpc_with_visibility(
519 runtime,
520 state.module_runtime.clone(),
521 state.contact_directory.as_ref(),
522 state.gateway_peer_keys.as_ref(),
523 state.console_events.clone(),
524 state.console_aggregator.clone(),
525 state.identity_runtime.clone(),
526 state.metadata_table.clone(),
527 state.mob_events.clone(),
528 state.visibility_policy.as_ref(),
529 parsed_request,
530 is_authenticated,
531 ))
532 .await;
533 (StatusCode::OK, Json::<Value>(response_value))
534}
535
536#[derive(Debug, serde::Deserialize)]
537struct ConsoleTimelineHttpQuery {
538 #[serde(default)]
539 identity: Option<String>,
540 #[serde(default)]
541 conversation_id: Option<String>,
542 #[serde(default)]
543 after: Option<String>,
544 #[serde(default)]
545 before: Option<String>,
546 #[serde(default)]
547 mode: Option<ConsoleTimelineMode>,
548 #[serde(default)]
549 limit: Option<usize>,
550}
551
552async fn console_identities_handler(
553 State(state): State<ConsoleJsonState>,
554 headers: HeaderMap,
555 uri: Uri,
556) -> impl IntoResponse {
557 if !console_request_authorized(&state, &headers, &uri) {
558 return console_json_error(
559 StatusCode::UNAUTHORIZED,
560 "unauthorized",
561 "console identities require a valid auth token",
562 );
563 }
564 let Some(aggregator) = &state.console_aggregator else {
565 return console_json_error(
566 StatusCode::NOT_FOUND,
567 "unavailable",
568 "console aggregator unavailable",
569 );
570 };
571 let aggregator = aggregator.clone();
572 match aggregator.list_identities().await {
573 Ok(identities) => (
574 StatusCode::OK,
575 Json::<Value>(json!({ "identities": identities })),
576 )
577 .into_response(),
578 Err(err) => console_json_error(
579 StatusCode::INTERNAL_SERVER_ERROR,
580 "internal_error",
581 &err.to_string(),
582 ),
583 }
584}
585
586async fn console_timeline_handler(
587 State(state): State<ConsoleJsonState>,
588 headers: HeaderMap,
589 uri: Uri,
590 Query(query): Query<ConsoleTimelineHttpQuery>,
591) -> impl IntoResponse {
592 if !console_request_authorized(&state, &headers, &uri) {
593 return console_json_error(
594 StatusCode::UNAUTHORIZED,
595 "unauthorized",
596 "console timeline requires a valid auth token",
597 );
598 }
599 let Some(aggregator) = &state.console_aggregator else {
600 return console_json_error(
601 StatusCode::NOT_FOUND,
602 "unavailable",
603 "console aggregator unavailable",
604 );
605 };
606 let timeline_query = timeline_query_from_http(query, None);
607 match Box::pin(aggregator.query_timeline_windowed(timeline_query)).await {
608 Ok(page) => (
609 StatusCode::OK,
610 Json::<Value>(serde_json::to_value(page).unwrap_or_else(|_| json!({ "frames": [] }))),
611 )
612 .into_response(),
613 Err(err) => {
614 console_json_error(StatusCode::CONFLICT, "replay_unavailable", &err.to_string())
615 }
616 }
617}
618
619async fn console_send_handler(
620 State(state): State<ConsoleJsonState>,
621 headers: HeaderMap,
622 uri: Uri,
623 Json(request): Json<ConsoleSendRequest>,
624) -> impl IntoResponse {
625 if !console_request_authorized(&state, &headers, &uri) {
626 return console_json_error(
627 StatusCode::UNAUTHORIZED,
628 "unauthorized",
629 "console send requires a valid auth token",
630 );
631 }
632 let Some(aggregator) = &state.console_aggregator else {
633 return console_json_error(
634 StatusCode::NOT_FOUND,
635 "unavailable",
636 "console aggregator unavailable",
637 );
638 };
639 if let Some(identity_runtime) = &state.identity_runtime {
640 return match Box::pin(console_send_with_identity_first_fallback(
641 aggregator,
642 identity_runtime.clone(),
643 state.console_events.as_ref(),
644 request,
645 ))
646 .await
647 {
648 Ok(accepted) => (
649 StatusCode::OK,
650 Json::<Value>(
651 serde_json::to_value(accepted).unwrap_or_else(|_| json!({ "accepted": true })),
652 ),
653 )
654 .into_response(),
655 Err(err) => console_send_error_response(err),
656 };
657 }
658 match Box::pin(aggregator.send(request)).await {
659 Ok(accepted) => (
660 StatusCode::OK,
661 Json::<Value>(
662 serde_json::to_value(accepted).unwrap_or_else(|_| json!({ "accepted": true })),
663 ),
664 )
665 .into_response(),
666 Err(err) => console_send_error_response(err),
667 }
668}
669
670async fn console_send_with_identity_first_fallback(
671 aggregator: &MobKitConsoleAggregator,
672 identity_runtime: Arc<crate::identity_first::IdentityRuntime>,
673 console_events: Option<&ConsoleEventStore>,
674 request: ConsoleSendRequest,
675) -> Result<crate::console_aggregator::ConsoleInteractionAccepted, ConsoleSendError> {
676 let member_send_request = request.clone();
677 match Box::pin(console_send_identity_first(
678 aggregator,
679 identity_runtime,
680 console_events,
681 request,
682 ))
683 .await
684 {
685 Err(ConsoleSendError::UnknownIdentity(_)) => {
686 Box::pin(aggregator.send(member_send_request)).await
687 }
688 result => result,
689 }
690}
691
692async fn console_send_identity_first(
693 aggregator: &MobKitConsoleAggregator,
694 identity_runtime: Arc<crate::identity_first::IdentityRuntime>,
695 console_events: Option<&ConsoleEventStore>,
696 mut request: ConsoleSendRequest,
697) -> Result<crate::console_aggregator::ConsoleInteractionAccepted, ConsoleSendError> {
698 let requested_identity = request.identity.clone();
699 let parsed_identity = crate::identity_first::AgentIdentity::parse(request.identity.as_str())
700 .map_err(|err| ConsoleSendError::InvalidRequest(format!("invalid identity: {err}")))?;
701 let content: ContentInput = serde_json::from_value(request.content.clone())
702 .map_err(|err| ConsoleSendError::InvalidContent(err.to_string()))?;
703 if let ContentInput::Text(text) = &content
704 && text.trim().is_empty()
705 {
706 return Err(ConsoleSendError::InvalidContent(
707 "content must be non-empty".to_string(),
708 ));
709 }
710 if let ContentInput::Blocks(blocks) = &content
711 && blocks.is_empty()
712 {
713 return Err(ConsoleSendError::InvalidContent(
714 "content blocks must be non-empty".to_string(),
715 ));
716 }
717 let handling_mode = parse_identity_first_handling_mode(request.handling_mode.as_deref())?;
718
719 let (identity, status) = match identity_runtime.status(&parsed_identity).await {
720 Ok(status) => (parsed_identity, status),
721 Err(original_err) => {
722 let Some(canonical_identity) =
723 resolve_console_send_identity_alias(aggregator, &requested_identity).await
724 else {
725 return Err(identity_runtime_error_to_console_send_error(
726 requested_identity.as_str(),
727 original_err,
728 ));
729 };
730 let identity = crate::identity_first::AgentIdentity::parse(canonical_identity.as_str())
731 .map_err(|err| {
732 ConsoleSendError::InvalidRequest(format!("invalid aliased identity: {err}"))
733 })?;
734 let status = identity_runtime.status(&identity).await.map_err(|_| {
735 identity_runtime_error_to_console_send_error(
736 requested_identity.as_str(),
737 original_err,
738 )
739 })?;
740 request.identity = canonical_identity;
741 (identity, status)
742 }
743 };
744 let session_id = status
745 .session_id
746 .as_ref()
747 .map(std::string::ToString::to_string);
748 let runtime_member_id = status
749 .agent_runtime_id
750 .as_ref()
751 .map(|id| id.as_str().to_string());
752 let accepted = Box::pin(
753 aggregator.reserve_identity_first_interaction(request.clone(), session_id.as_deref()),
754 )
755 .await?;
756
757 if let Some(events) = console_events {
758 events
759 .reserve_interaction_value(
760 identity.as_str(),
761 runtime_member_id.as_deref(),
762 &accepted.interaction_id,
763 &request.origin,
764 request.content.clone(),
765 )
766 .await
767 .map_err(ConsoleSendError::State)?;
768 }
769
770 if handling_mode == meerkat_core::types::HandlingMode::Steer {
771 match identity_runtime
772 .send_with_mode(&identity, &content, handling_mode)
773 .await
774 {
775 Ok(_) => {
776 if let Err(err) = aggregator
777 .mark_steer_interaction_delivered(
778 &accepted.input_frame_id,
779 &accepted.interaction_id,
780 )
781 .await
782 {
783 tracing::warn!(
784 identity = %identity,
785 error = %err,
786 "console identity-first steer was admitted but delivery status projection failed"
787 );
788 }
789 }
790 Err(err) => {
791 let _ = aggregator
792 .mark_interaction_delivery_failed(&accepted.input_frame_id)
793 .await;
794 if let Some(events) = console_events {
795 events
796 .record_lifecycle(
797 identity.as_str(),
798 "interaction_failed",
799 json!({
800 "interaction_id": accepted.interaction_id,
801 "origin": request.origin,
802 "error": err.to_string(),
803 }),
804 )
805 .await;
806 }
807 tracing::warn!(
808 identity = %identity,
809 error = %err,
810 "console identity-first steer was accepted but delivery failed"
811 );
812 return Err(identity_runtime_error_to_console_send_error(
813 identity.as_str(),
814 err,
815 ));
816 }
817 }
818 return Ok(accepted);
819 }
820
821 let dispatch_aggregator = aggregator.clone();
822 let dispatch_events = console_events.cloned();
823 let dispatch_identity = identity.clone();
824 let dispatch_content = content.clone();
825 let dispatch_origin = request.origin.clone();
826 let dispatch_accepted = accepted.clone();
827 tokio::spawn(async move {
828 match identity_runtime
829 .send_with_mode(&dispatch_identity, &dispatch_content, handling_mode)
830 .await
831 {
832 Ok(_) => {
833 if let Err(err) = dispatch_aggregator
834 .mark_interaction_delivered(&dispatch_accepted.input_frame_id)
835 .await
836 {
837 tracing::warn!(
838 identity = %dispatch_identity,
839 error = %err,
840 "console identity-first send was accepted but delivery status projection failed"
841 );
842 }
843 }
844 Err(err) => {
845 let _ = dispatch_aggregator
846 .mark_interaction_delivery_failed(&dispatch_accepted.input_frame_id)
847 .await;
848 if let Some(events) = dispatch_events {
849 events
850 .record_lifecycle(
851 dispatch_identity.as_str(),
852 "interaction_failed",
853 json!({
854 "interaction_id": dispatch_accepted.interaction_id,
855 "origin": dispatch_origin,
856 "error": err.to_string(),
857 }),
858 )
859 .await;
860 }
861 tracing::warn!(
862 identity = %dispatch_identity,
863 error = %err,
864 "console identity-first send was accepted but delivery failed"
865 );
866 }
867 }
868 });
869 Ok(accepted)
870}
871
872fn parse_identity_first_handling_mode(
873 value: Option<&str>,
874) -> Result<meerkat_core::types::HandlingMode, ConsoleSendError> {
875 match value.unwrap_or("queue") {
876 "queue" => Ok(meerkat_core::types::HandlingMode::Queue),
877 "steer" => Ok(meerkat_core::types::HandlingMode::Steer),
878 other => Err(ConsoleSendError::InvalidHandlingMode(other.to_string())),
879 }
880}
881
882async fn resolve_console_send_identity_alias(
883 aggregator: &MobKitConsoleAggregator,
884 requested_identity: &str,
885) -> Option<String> {
886 let identities = aggregator.list_identities().await.ok()?;
887 identities
888 .into_iter()
889 .find(|record| {
890 record.identity == requested_identity || record.runtime_member_id == requested_identity
891 })
892 .map(|record| record.identity)
893}
894
895fn identity_runtime_error_to_console_send_error(
896 identity: &str,
897 err: crate::identity_first::IdentityRuntimeError,
898) -> ConsoleSendError {
899 match err {
900 crate::identity_first::IdentityRuntimeError::UnknownIdentity(_) => {
901 ConsoleSendError::UnknownIdentity(identity.to_string())
902 }
903 crate::identity_first::IdentityRuntimeError::NotAddressable(_) => {
904 ConsoleSendError::NotAddressable(identity.to_string())
905 }
906 crate::identity_first::IdentityRuntimeError::InvalidState { .. } => {
907 ConsoleSendError::Retired(identity.to_string())
908 }
909 other => ConsoleSendError::Dispatch(other.to_string()),
910 }
911}
912
913async fn console_timeline_stream_handler(
914 State(state): State<ConsoleJsonState>,
915 headers: HeaderMap,
916 uri: Uri,
917 Query(query): Query<ConsoleTimelineHttpQuery>,
918) -> impl IntoResponse {
919 if !console_request_authorized(&state, &headers, &uri) {
920 return console_json_error(
921 StatusCode::UNAUTHORIZED,
922 "unauthorized",
923 "console timeline stream requires a valid auth token",
924 );
925 }
926 let Some(aggregator) = &state.console_aggregator else {
927 return console_json_error(
928 StatusCode::NOT_FOUND,
929 "unavailable",
930 "console aggregator unavailable",
931 );
932 };
933 let aggregator = aggregator.clone();
934 let last_event_id = headers
935 .get("last-event-id")
936 .and_then(|value| value.to_str().ok())
937 .map(str::trim)
938 .filter(|value| !value.is_empty())
939 .map(ToString::to_string);
940 let timeline_query = timeline_query_from_http(query, last_event_id);
941 let mut rx = aggregator.subscribe();
942 let (snapshot_frames, snapshot_cursor) =
943 match Box::pin(query_timeline_snapshot(&aggregator, timeline_query.clone())).await {
944 Ok(snapshot) => snapshot,
945 Err(_) => {
946 let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
947 let requested_cursor = timeline_query
948 .after
949 .as_ref()
950 .map(ToString::to_string)
951 .unwrap_or_default();
952 return (
953 StatusCode::CONFLICT,
954 Json::<Value>(
955 serde_json::to_value(ConsoleReplayUnavailable {
956 error: "replay_unavailable".to_string(),
957 requested_cursor,
958 latest_cursor,
959 })
960 .unwrap_or_else(|_| json!({ "error": "replay_unavailable" })),
961 ),
962 )
963 .into_response();
964 }
965 };
966 let identity = timeline_query.identity.clone();
967 let conversation_id = timeline_query.conversation_id.clone();
968 let snapshot_after = timeline_query.after.clone();
969 let stream = stream! {
970 if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::SnapshotStarted { after: snapshot_after }) {
971 yield Ok::<Event, Infallible>(event);
972 }
973 let mut latest_cursor = snapshot_cursor;
974 for frame in snapshot_frames {
975 latest_cursor = Some(frame.cursor.clone());
976 if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::ConsoleFrame { frame }) {
977 yield Ok::<Event, Infallible>(event);
978 }
979 }
980 if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::SnapshotComplete { cursor: latest_cursor.clone() }) {
981 yield Ok::<Event, Infallible>(event);
982 }
983 loop {
984 match rx.recv().await {
985 Ok(event) if timeline_event_matches(&event, identity.as_deref(), conversation_id.as_deref()) => {
986 if !aggregator.timeline_event_visible(&event).await {
987 continue;
988 }
989 if let Some(event_cursor) = timeline_event_cursor(&event)
990 && let Some(current_cursor) = latest_cursor.as_ref()
991 && !cursor_is_after(event_cursor, current_cursor)
992 {
993 continue;
994 }
995 if let Some(sse) = sse_event_from_timeline_event(&event) {
996 if let Some(event_cursor) = timeline_event_cursor(&event) {
997 latest_cursor = Some(event_cursor.clone());
998 }
999 yield Ok::<Event, Infallible>(sse);
1000 }
1001 }
1002 Ok(_) => {}
1003 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => break,
1004 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1005 }
1006 }
1007 };
1008 Sse::new(stream)
1009 .keep_alive(
1010 KeepAlive::new()
1011 .interval(DEFAULT_KEEP_ALIVE_INTERVAL)
1012 .text(KEEP_ALIVE_TEXT),
1013 )
1014 .into_response()
1015}
1016
1017async fn console_identity_timeline_stream_handler(
1018 State(state): State<ConsoleJsonState>,
1019 headers: HeaderMap,
1020 uri: Uri,
1021 AxumPath(identity): AxumPath<String>,
1022 Query(mut query): Query<ConsoleTimelineHttpQuery>,
1023) -> impl IntoResponse {
1024 query.identity = Some(identity);
1025 Box::pin(console_timeline_stream_handler(
1026 State(state),
1027 headers,
1028 uri,
1029 Query(query),
1030 ))
1031 .await
1032 .into_response()
1033}
1034
1035fn timeline_query_from_http(
1036 query: ConsoleTimelineHttpQuery,
1037 fallback_after: Option<String>,
1038) -> ConsoleTimelineWindowQuery {
1039 let after = fallback_after.or(query.after).map(ConsoleCursor::from);
1040 let before = query.before.map(ConsoleCursor::from);
1041 ConsoleTimelineWindowQuery {
1042 identity: query
1043 .identity
1044 .map(|value| value.trim().to_string())
1045 .filter(|value| !value.is_empty()),
1046 conversation_id: query
1047 .conversation_id
1048 .map(|value| value.trim().to_string())
1049 .filter(|value| !value.is_empty()),
1050 after,
1051 before,
1052 mode: query.mode.unwrap_or_default(),
1053 limit: query.limit.unwrap_or(200),
1054 }
1055}
1056
1057async fn query_timeline_snapshot(
1058 aggregator: &MobKitConsoleAggregator,
1059 mut query: ConsoleTimelineWindowQuery,
1060) -> ConsoleLogResult<(Vec<ConsoleFrame>, Option<ConsoleCursor>)> {
1061 const DEFAULT_SNAPSHOT_LIMIT: usize = 200;
1062 query.limit = if query.limit == 0 {
1063 DEFAULT_SNAPSHOT_LIMIT
1064 } else {
1065 query.limit
1066 };
1067 if query.after.is_none() && query.mode == ConsoleTimelineMode::Since {
1068 query.mode = ConsoleTimelineMode::Recent;
1069 }
1070 let mode = query.mode;
1071 match mode {
1072 ConsoleTimelineMode::Recent => {
1073 let page = Box::pin(aggregator.query_timeline_windowed(query)).await?;
1074 Ok((page.frames, page.latest_cursor.or(page.next_cursor)))
1075 }
1076 ConsoleTimelineMode::Since => {
1077 if let (Some(after), Some(latest)) =
1078 (query.after.as_ref(), aggregator.latest_cursor().await?)
1079 && let (Some(after_seq), Some(latest_seq)) = (after.seq(), latest.seq())
1080 && after_seq > latest_seq
1081 {
1082 return Err(std::io::Error::other(
1083 "timeline replay cursor is beyond the current store frontier",
1084 )
1085 .into());
1086 }
1087 let mut frames = Vec::new();
1088 let mut cursor = query.after.clone();
1089 let mut latest_cursor = None;
1090 loop {
1091 let page = Box::pin(aggregator.query_timeline_windowed(query.clone())).await?;
1092 latest_cursor = page.latest_cursor.clone().or(latest_cursor);
1093 if !page.frames.is_empty() {
1094 cursor = page
1095 .next_cursor
1096 .clone()
1097 .or_else(|| page.frames.last().map(|frame| frame.cursor.clone()));
1098 frames.extend(page.frames);
1099 } else if page.next_cursor.is_some() {
1100 cursor = page.next_cursor.clone();
1101 }
1102 if page.exhausted || page.next_cursor.is_none() {
1103 return Ok((frames, cursor.or(latest_cursor)));
1104 }
1105 if page.next_cursor == query.after {
1106 return Err(
1107 std::io::Error::other("timeline replay made no cursor progress").into(),
1108 );
1109 }
1110 query.after = page.next_cursor;
1111 }
1112 }
1113 }
1114}
1115
1116fn console_json_error(status: StatusCode, error: &str, message: &str) -> axum::response::Response {
1117 (
1118 status,
1119 Json::<Value>(json!({
1120 "error": error,
1121 "message": message,
1122 })),
1123 )
1124 .into_response()
1125}
1126
1127fn console_send_error_response(err: ConsoleSendError) -> axum::response::Response {
1128 let (status, code) = match &err {
1129 ConsoleSendError::UnknownIdentity(_) => (StatusCode::NOT_FOUND, "unknown_identity"),
1130 ConsoleSendError::AmbiguousIdentity { .. } => {
1131 (StatusCode::CONFLICT, "ambiguous_live_identity_alias")
1132 }
1133 ConsoleSendError::NotAddressable(_) => (StatusCode::CONFLICT, "not_addressable"),
1134 ConsoleSendError::Retired(_) => (StatusCode::CONFLICT, "retired"),
1135 ConsoleSendError::InvalidContent(_)
1136 | ConsoleSendError::InvalidHandlingMode(_)
1137 | ConsoleSendError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"),
1138 ConsoleSendError::IdempotencyConflict(_) => (StatusCode::CONFLICT, "idempotency_conflict"),
1139 ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1140 (StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
1141 }
1142 };
1143 console_json_error(status, code, &err.to_string())
1144}
1145
1146fn console_send_rpc_code(err: &ConsoleSendError) -> i64 {
1147 match err {
1148 ConsoleSendError::UnknownIdentity(_) => -32001,
1149 ConsoleSendError::AmbiguousIdentity { .. } => -32602,
1150 ConsoleSendError::NotAddressable(_) => -32002,
1151 ConsoleSendError::InvalidContent(_)
1152 | ConsoleSendError::InvalidHandlingMode(_)
1153 | ConsoleSendError::InvalidRequest(_) => -32602,
1154 ConsoleSendError::IdempotencyConflict(_) => -32009,
1155 ConsoleSendError::Retired(_) => -32004,
1156 ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1157 -32000
1158 }
1159 }
1160}
1161
1162fn console_send_rpc_error(response_id: Value, err: ConsoleSendError) -> Value {
1163 response_value(
1164 response_id,
1165 None,
1166 Some(JsonRpcError {
1167 code: console_send_rpc_code(&err),
1168 message: err.to_string(),
1169 data: None,
1170 }),
1171 )
1172}
1173
1174fn timeline_event_matches(
1175 event: &ConsoleTimelineEvent,
1176 identity: Option<&str>,
1177 conversation_id: Option<&str>,
1178) -> bool {
1179 let frame = match event {
1180 ConsoleTimelineEvent::ConsoleFrame { frame }
1181 | ConsoleTimelineEvent::FrameUpdated { frame } => frame,
1182 ConsoleTimelineEvent::SnapshotStarted { .. }
1183 | ConsoleTimelineEvent::SnapshotComplete { .. }
1184 | ConsoleTimelineEvent::ReplayUnavailable { .. } => return true,
1185 };
1186 if identity.is_some_and(|value| frame.identity != value) {
1187 return false;
1188 }
1189 if conversation_id.is_some_and(|value| frame.conversation_id.as_deref() != Some(value)) {
1190 return false;
1191 }
1192 true
1193}
1194
1195fn timeline_event_cursor(event: &ConsoleTimelineEvent) -> Option<&ConsoleCursor> {
1196 match event {
1197 ConsoleTimelineEvent::ConsoleFrame { frame }
1198 | ConsoleTimelineEvent::FrameUpdated { frame } => Some(&frame.cursor),
1199 ConsoleTimelineEvent::SnapshotStarted { .. }
1200 | ConsoleTimelineEvent::SnapshotComplete { .. }
1201 | ConsoleTimelineEvent::ReplayUnavailable { .. } => None,
1202 }
1203}
1204
1205fn cursor_is_after(candidate: &ConsoleCursor, current: &ConsoleCursor) -> bool {
1206 match (candidate.seq(), current.seq()) {
1207 (Some(candidate), Some(current)) => candidate > current,
1208 _ => candidate > current,
1209 }
1210}
1211
1212fn sse_event_from_timeline_event(event: &ConsoleTimelineEvent) -> Option<Event> {
1213 let (event_name, id) = match event {
1214 ConsoleTimelineEvent::SnapshotStarted { .. } => ("snapshot_started", None),
1215 ConsoleTimelineEvent::ConsoleFrame { frame } => (
1216 if frame.kind == "frame_updated" {
1217 "frame_updated"
1218 } else {
1219 "console_frame"
1220 },
1221 Some(frame.cursor.to_string()),
1222 ),
1223 ConsoleTimelineEvent::FrameUpdated { frame } => {
1224 ("frame_updated", Some(frame.cursor.to_string()))
1225 }
1226 ConsoleTimelineEvent::SnapshotComplete { cursor } => (
1227 "snapshot_complete",
1228 cursor.as_ref().map(ToString::to_string),
1229 ),
1230 ConsoleTimelineEvent::ReplayUnavailable { .. } => ("replay_unavailable", None),
1231 };
1232 let data = match serde_json::to_string(event) {
1233 Ok(value) => value,
1234 Err(_) => return None,
1235 };
1236 let mut sse = Event::default().event(event_name).data(data);
1237 if let Some(id) = id {
1238 sse = sse.id(id);
1239 }
1240 Some(sse)
1241}
1242
1243pub async fn console_rpc_multipart_handler(
1244 State(state): State<ConsoleJsonState>,
1245 headers: HeaderMap,
1246 uri: Uri,
1247 mut multipart: Multipart,
1248) -> impl IntoResponse {
1249 if !console_request_authorized(&state, &headers, &uri) {
1250 return (
1251 StatusCode::UNAUTHORIZED,
1252 Json::<Value>(serde_json::json!({
1253 "jsonrpc": JSONRPC_VERSION,
1254 "id": Value::Null,
1255 "error": {
1256 "code": -32600,
1257 "message": "unauthorized: console rpc requires a valid auth token",
1258 }
1259 })),
1260 );
1261 }
1262
1263 let mut payload: Option<String> = None;
1264 let mut files: std::collections::BTreeMap<String, MultipartImageUpload> =
1265 std::collections::BTreeMap::new();
1266
1267 while let Some(mut field) = match multipart.next_field().await {
1268 Ok(field) => field,
1269 Err(err) => {
1270 return (
1271 StatusCode::BAD_REQUEST,
1272 Json::<Value>(json_rpc_error_value(
1273 Value::Null,
1274 -32602,
1275 format!("invalid multipart body: {err}"),
1276 )),
1277 );
1278 }
1279 } {
1280 let name = field.name().unwrap_or("").to_string();
1281 if name == "payload" {
1282 if payload.is_some() {
1283 return (
1284 StatusCode::BAD_REQUEST,
1285 Json::<Value>(json_rpc_error_value(
1286 Value::Null,
1287 -32602,
1288 "duplicate payload part",
1289 )),
1290 );
1291 }
1292 payload = match field.text().await {
1293 Ok(text) => Some(text),
1294 Err(err) => {
1295 return (
1296 StatusCode::BAD_REQUEST,
1297 Json::<Value>(json_rpc_error_value(
1298 Value::Null,
1299 -32602,
1300 format!("invalid payload part: {err}"),
1301 )),
1302 );
1303 }
1304 };
1305 continue;
1306 }
1307
1308 let Some(upload_id) = name.strip_prefix("file:").filter(|id| !id.is_empty()) else {
1309 return (
1310 StatusCode::BAD_REQUEST,
1311 Json::<Value>(json_rpc_error_value(
1312 Value::Null,
1313 -32602,
1314 format!("unexpected multipart field: {name}"),
1315 )),
1316 );
1317 };
1318 if files.len() >= MAX_MULTIPART_IMAGES {
1319 return (
1320 StatusCode::BAD_REQUEST,
1321 Json::<Value>(json_rpc_error_value(
1322 Value::Null,
1323 -32602,
1324 format!("too many image attachments; max {MAX_MULTIPART_IMAGES}"),
1325 )),
1326 );
1327 }
1328 if files.contains_key(upload_id) {
1329 return (
1330 StatusCode::BAD_REQUEST,
1331 Json::<Value>(json_rpc_error_value(
1332 Value::Null,
1333 -32602,
1334 format!("duplicate file part for upload_id {upload_id}"),
1335 )),
1336 );
1337 }
1338 let media_type = field
1339 .content_type()
1340 .map(str::to_string)
1341 .unwrap_or_else(|| "application/octet-stream".to_string());
1342 if !is_allowed_image_media_type(&media_type) {
1343 return (
1344 StatusCode::BAD_REQUEST,
1345 Json::<Value>(json_rpc_error_value(
1346 Value::Null,
1347 -32602,
1348 format!("unsupported image media type: {media_type}"),
1349 )),
1350 );
1351 }
1352 let mut bytes = bytes::BytesMut::new();
1353 loop {
1354 let chunk = match field.chunk().await {
1355 Ok(chunk) => chunk,
1356 Err(err) => {
1357 return (
1358 StatusCode::BAD_REQUEST,
1359 Json::<Value>(json_rpc_error_value(
1360 Value::Null,
1361 -32602,
1362 format!("invalid file part {upload_id}: {err}"),
1363 )),
1364 );
1365 }
1366 };
1367 let Some(chunk) = chunk else {
1368 break;
1369 };
1370 if bytes.len() + chunk.len() > MAX_MULTIPART_IMAGE_BYTES {
1371 return (
1372 StatusCode::BAD_REQUEST,
1373 Json::<Value>(json_rpc_error_value(
1374 Value::Null,
1375 -32602,
1376 format!("image attachment {upload_id} exceeds 25 MiB"),
1377 )),
1378 );
1379 }
1380 bytes.extend_from_slice(&chunk);
1381 }
1382 files.insert(
1383 upload_id.to_string(),
1384 MultipartImageUpload {
1385 media_type,
1386 bytes: bytes.freeze(),
1387 },
1388 );
1389 }
1390
1391 let payload = match payload {
1392 Some(payload) => payload,
1393 None => {
1394 return (
1395 StatusCode::BAD_REQUEST,
1396 Json::<Value>(json_rpc_error_value(
1397 Value::Null,
1398 -32602,
1399 "payload part required",
1400 )),
1401 );
1402 }
1403 };
1404 let mut parsed_request = match serde_json::from_str::<JsonRpcRequest>(&payload) {
1405 Ok(req) => req,
1406 Err(err) => {
1407 return (
1408 StatusCode::OK,
1409 Json::<Value>(json_rpc_error_value(
1410 Value::Null,
1411 -32600,
1412 format!("Invalid Request: {err}"),
1413 )),
1414 );
1415 }
1416 };
1417 let response_id = parsed_request.id.clone().unwrap_or(Value::Null);
1418 match parsed_request.method.as_str() {
1419 "mobkit/console/send" => {
1420 let Some(aggregator) = &state.console_aggregator else {
1421 return (
1422 StatusCode::OK,
1423 Json::<Value>(invalid_params(
1424 response_id,
1425 "mobkit/console/send multipart requires a console aggregator",
1426 )),
1427 );
1428 };
1429 let Some(identity) = parsed_request
1430 .params
1431 .get("identity")
1432 .and_then(Value::as_str)
1433 else {
1434 return (
1435 StatusCode::OK,
1436 Json::<Value>(invalid_params(response_id, "identity required")),
1437 );
1438 };
1439 let binary_blob_store =
1440 match Box::pin(aggregator.binary_blob_store_for_identity(identity)).await {
1441 Ok(Some(store)) => store,
1442 Ok(None) => {
1443 return (
1444 StatusCode::OK,
1445 Json::<Value>(invalid_params(
1446 response_id,
1447 "binary blob store unavailable for identity",
1448 )),
1449 );
1450 }
1451 Err(err) => {
1452 return (
1453 StatusCode::OK,
1454 Json::<Value>(console_send_rpc_error(response_id, err)),
1455 );
1456 }
1457 };
1458 if let Err(message) = externalize_image_upload_placeholders(
1459 &mut parsed_request.params,
1460 files,
1461 binary_blob_store,
1462 )
1463 .await
1464 {
1465 return (
1466 StatusCode::OK,
1467 Json::<Value>(invalid_params(response_id, message)),
1468 );
1469 }
1470 }
1471 "mobkit/blob/upload" => {
1472 let Some(runtime) = &state.runtime else {
1473 return (
1474 StatusCode::NOT_FOUND,
1475 Json::<Value>(json_rpc_error_value(
1476 response_id,
1477 -32600,
1478 "mobkit/blob/upload multipart requires a unified runtime",
1479 )),
1480 );
1481 };
1482 let Some(binary_blob_store) = runtime.binary_blob_store() else {
1483 return (
1484 StatusCode::INTERNAL_SERVER_ERROR,
1485 Json::<Value>(json_rpc_error_value(
1486 response_id,
1487 -32000,
1488 "binary blob store unavailable",
1489 )),
1490 );
1491 };
1492 let result = match externalize_single_image_upload(
1493 &parsed_request.params,
1494 files,
1495 binary_blob_store,
1496 )
1497 .await
1498 {
1499 Ok(result) => result,
1500 Err(message) => {
1501 return (
1502 StatusCode::OK,
1503 Json::<Value>(invalid_params(response_id, message)),
1504 );
1505 }
1506 };
1507 return (
1508 StatusCode::OK,
1509 Json::<Value>(response_value(response_id, Some(result), None)),
1510 );
1511 }
1512 _ => {
1513 return (
1514 StatusCode::OK,
1515 Json::<Value>(invalid_params(
1516 response_id,
1517 "multipart RPC supports mobkit/console/send and mobkit/blob/upload only",
1518 )),
1519 );
1520 }
1521 }
1522 let response_value =
1523 if parsed_request.method == "mobkit/console/send" && state.runtime.is_none() {
1524 Box::pin(handle_console_aggregator_rpc(
1525 state.console_aggregator.clone(),
1526 parsed_request,
1527 true,
1528 ))
1529 .await
1530 } else {
1531 let Some(runtime) = &state.runtime else {
1532 return (
1533 StatusCode::NOT_FOUND,
1534 Json::<Value>(json_rpc_error_value(
1535 response_id,
1536 -32600,
1537 "console rpc multipart requires a unified runtime",
1538 )),
1539 );
1540 };
1541 Box::pin(handle_console_runtime_rpc_with_visibility(
1542 runtime,
1543 state.module_runtime.clone(),
1544 state.contact_directory.as_ref(),
1545 state.gateway_peer_keys.as_ref(),
1546 state.console_events.clone(),
1547 state.console_aggregator.clone(),
1548 state.identity_runtime.clone(),
1549 state.metadata_table.clone(),
1550 state.mob_events.clone(),
1551 state.visibility_policy.as_ref(),
1552 parsed_request,
1553 true,
1554 ))
1555 .await
1556 };
1557 (StatusCode::OK, Json::<Value>(response_value))
1558}
1559
1560pub async fn blob_get_handler(
1561 State(state): State<ConsoleJsonState>,
1562 headers: HeaderMap,
1563 uri: Uri,
1564 AxumPath(blob_id): AxumPath<String>,
1565) -> impl IntoResponse {
1566 if !console_request_authorized(&state, &headers, &uri) {
1567 return (
1568 StatusCode::UNAUTHORIZED,
1569 Json::<Value>(serde_json::json!({ "error": "unauthorized" })),
1570 )
1571 .into_response();
1572 }
1573 if !is_valid_blob_id_value(&blob_id) {
1574 return (
1575 StatusCode::BAD_REQUEST,
1576 Json::<Value>(serde_json::json!({ "error": "invalid_blob_id" })),
1577 )
1578 .into_response();
1579 }
1580 let blob_id = meerkat_core::BlobId::from(blob_id.as_str());
1581 let mut stores: Vec<std::sync::Arc<dyn BinaryBlobStore>> = Vec::new();
1582 if let Some(runtime) = &state.runtime
1583 && let Some(store) = runtime.binary_blob_store()
1584 {
1585 stores.push(store);
1586 }
1587 if let Some(aggregator) = &state.console_aggregator {
1588 stores.extend(aggregator.binary_blob_stores());
1589 }
1590 if stores.is_empty() {
1591 return (
1592 StatusCode::NOT_FOUND,
1593 Json::<Value>(serde_json::json!({ "error": "blob_store_unavailable" })),
1594 )
1595 .into_response();
1596 }
1597 for store in stores {
1598 match store.get_bytes(&blob_id).await {
1599 Ok(payload) => return blob_payload_response(payload),
1600 Err(meerkat_core::BlobStoreError::NotFound(_)) => continue,
1601 Err(err) => {
1602 return (
1603 StatusCode::INTERNAL_SERVER_ERROR,
1604 Json::<Value>(serde_json::json!({ "error": err.to_string() })),
1605 )
1606 .into_response();
1607 }
1608 }
1609 }
1610 (
1611 StatusCode::NOT_FOUND,
1612 Json::<Value>(serde_json::json!({ "error": "blob_not_found" })),
1613 )
1614 .into_response()
1615}
1616
1617fn blob_payload_response(payload: BinaryBlobPayload) -> axum::response::Response {
1618 let mut response_headers = HeaderMap::new();
1619 let content_type = HeaderValue::from_str(&payload.media_type)
1620 .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
1621 response_headers.insert(header::CONTENT_TYPE, content_type);
1622 if let Ok(content_length) = HeaderValue::from_str(&payload.size.to_string()) {
1623 response_headers.insert(header::CONTENT_LENGTH, content_length);
1624 }
1625 response_headers.insert(
1626 header::CACHE_CONTROL,
1627 HeaderValue::from_static("private, max-age=31536000, immutable"),
1628 );
1629 (StatusCode::OK, response_headers, payload.data).into_response()
1630}
1631
1632fn console_request_authorized(state: &ConsoleJsonState, headers: &HeaderMap, uri: &Uri) -> bool {
1633 if !state.decisions.console.require_app_auth {
1634 return true;
1635 }
1636 console_request_token(headers, uri)
1637 .is_some_and(|token| validate_console_token(&state.decisions, &token))
1638}
1639
1640fn console_request_token(headers: &HeaderMap, uri: &Uri) -> Option<String> {
1641 let bearer_token = headers
1642 .get(header::AUTHORIZATION)
1643 .and_then(|v| v.to_str().ok())
1644 .and_then(extract_bearer_token_from_header)
1645 .map(String::from);
1646 let query_token = uri.query().and_then(|q| {
1649 form_urlencoded::parse(q.as_bytes())
1650 .find(|(key, _)| key == "auth_token")
1651 .map(|(_, value)| value.into_owned())
1652 });
1653 bearer_token.or(query_token)
1654}
1655
1656#[derive(Debug)]
1657struct MultipartImageUpload {
1658 media_type: String,
1659 bytes: bytes::Bytes,
1660}
1661
1662fn json_rpc_error_value(id: Value, code: i64, message: impl Into<String>) -> Value {
1663 serde_json::json!({
1664 "jsonrpc": JSONRPC_VERSION,
1665 "id": id,
1666 "error": {
1667 "code": code,
1668 "message": message.into(),
1669 }
1670 })
1671}
1672
1673fn is_allowed_image_media_type(media_type: &str) -> bool {
1674 matches!(
1675 media_type,
1676 "image/png" | "image/jpeg" | "image/webp" | "image/gif"
1677 )
1678}
1679
1680fn image_upload_part_name<'a>(
1681 object: &'a serde_json::Map<String, Value>,
1682 context: &str,
1683) -> Result<&'a str, String> {
1684 object
1685 .get("upload_id")
1686 .or_else(|| object.get("part_name"))
1687 .and_then(Value::as_str)
1688 .map(str::trim)
1689 .filter(|value| !value.is_empty())
1690 .ok_or_else(|| format!("{context}.upload_id or {context}.part_name is required"))
1691}
1692
1693async fn externalize_image_upload_placeholders(
1694 params: &mut Value,
1695 files: std::collections::BTreeMap<String, MultipartImageUpload>,
1696 blob_store: std::sync::Arc<dyn crate::blob_store::BinaryBlobStore>,
1697) -> Result<(), String> {
1698 let Some(content) = params.get_mut("content") else {
1699 return Err("multipart payload params.content is required".to_string());
1700 };
1701 let mut placeholders = std::collections::BTreeMap::<String, String>::new();
1702 collect_image_upload_placeholders(content, &mut placeholders)?;
1703 if placeholders.is_empty() {
1704 return Err(
1705 "multipart payload must contain at least one image_upload placeholder".to_string(),
1706 );
1707 }
1708 if placeholders.len() > MAX_MULTIPART_IMAGES {
1709 return Err(format!(
1710 "too many image_upload placeholders; max {MAX_MULTIPART_IMAGES}"
1711 ));
1712 }
1713 for upload_id in files.keys() {
1714 if !placeholders.contains_key(upload_id) {
1715 return Err(format!(
1716 "file part has no matching image_upload placeholder: {upload_id}"
1717 ));
1718 }
1719 }
1720 for upload_id in placeholders.keys() {
1721 if !files.contains_key(upload_id) {
1722 return Err(format!(
1723 "image_upload placeholder missing file part: {upload_id}"
1724 ));
1725 }
1726 }
1727
1728 let mut refs = std::collections::BTreeMap::<String, Value>::new();
1729 for (upload_id, file) in files {
1730 let declared_media_type = placeholders
1731 .get(&upload_id)
1732 .cloned()
1733 .unwrap_or_else(|| file.media_type.clone());
1734 if !is_allowed_image_media_type(&declared_media_type) {
1735 return Err(format!(
1736 "unsupported image media type in placeholder {upload_id}: {declared_media_type}"
1737 ));
1738 }
1739 if declared_media_type != file.media_type {
1740 return Err(format!(
1741 "media type mismatch for {upload_id}: placeholder {declared_media_type}, file {}",
1742 file.media_type
1743 ));
1744 }
1745 let blob_ref = blob_store
1746 .put_bytes(&file.media_type, file.bytes)
1747 .await
1748 .map_err(|err| format!("failed to store image {upload_id}: {err}"))?;
1749 refs.insert(
1750 upload_id,
1751 serde_json::json!({
1752 "type": "image",
1753 "media_type": blob_ref.media_type,
1754 "source": "blob",
1755 "blob_id": blob_ref.blob_id,
1756 }),
1757 );
1758 }
1759 replace_image_upload_placeholders(content, &refs)?;
1760 if let Some(object) = params.as_object_mut() {
1761 object.remove("message");
1762 }
1763 Ok(())
1764}
1765
1766async fn externalize_single_image_upload(
1767 params: &Value,
1768 files: std::collections::BTreeMap<String, MultipartImageUpload>,
1769 blob_store: std::sync::Arc<dyn crate::blob_store::BinaryBlobStore>,
1770) -> Result<Value, String> {
1771 let upload = params.get("upload").unwrap_or(params);
1772 if upload
1773 .get("type")
1774 .and_then(Value::as_str)
1775 .is_some_and(|kind| kind != "image_upload")
1776 {
1777 return Err("upload.type must be image_upload".to_string());
1778 }
1779 let upload_object = upload
1780 .as_object()
1781 .ok_or_else(|| "upload must be an object".to_string())?;
1782 let upload_id = image_upload_part_name(upload_object, "upload")?;
1783 let Some(file) = files.get(upload_id) else {
1784 return Err(format!(
1785 "image_upload placeholder missing file part: {upload_id}"
1786 ));
1787 };
1788 if files.len() != 1 {
1789 return Err("mobkit/blob/upload accepts exactly one file part".to_string());
1790 }
1791 let declared_media_type = upload
1792 .get("media_type")
1793 .and_then(Value::as_str)
1794 .unwrap_or(file.media_type.as_str());
1795 if !is_allowed_image_media_type(declared_media_type) {
1796 return Err(format!(
1797 "unsupported image media type in upload {upload_id}: {declared_media_type}"
1798 ));
1799 }
1800 if declared_media_type != file.media_type {
1801 return Err(format!(
1802 "media type mismatch for {upload_id}: placeholder {declared_media_type}, file {}",
1803 file.media_type
1804 ));
1805 }
1806 let size = file.bytes.len() as u64;
1807 let blob_ref = blob_store
1808 .put_bytes(&file.media_type, file.bytes.clone())
1809 .await
1810 .map_err(|err| format!("failed to store image {upload_id}: {err}"))?;
1811 Ok(json!({
1812 "blob_id": blob_ref.blob_id,
1813 "media_type": blob_ref.media_type,
1814 "size": size,
1815 }))
1816}
1817
1818fn collect_image_upload_placeholders(
1819 value: &Value,
1820 placeholders: &mut std::collections::BTreeMap<String, String>,
1821) -> Result<(), String> {
1822 match value {
1823 Value::Array(items) => {
1824 for item in items {
1825 collect_image_upload_placeholders(item, placeholders)?;
1826 }
1827 }
1828 Value::Object(object) => {
1829 if object.get("type").and_then(Value::as_str) == Some("image_upload") {
1830 let upload_id = image_upload_part_name(object, "image_upload")?;
1831 let media_type = object
1832 .get("media_type")
1833 .and_then(Value::as_str)
1834 .map(str::trim)
1835 .filter(|value| !value.is_empty())
1836 .ok_or_else(|| format!("image_upload {upload_id} requires media_type"))?;
1837 if placeholders
1838 .insert(upload_id.to_string(), media_type.to_string())
1839 .is_some()
1840 {
1841 return Err(format!("duplicate image_upload placeholder: {upload_id}"));
1842 }
1843 } else {
1844 for child in object.values() {
1845 collect_image_upload_placeholders(child, placeholders)?;
1846 }
1847 }
1848 }
1849 _ => {}
1850 }
1851 Ok(())
1852}
1853
1854fn replace_image_upload_placeholders(
1855 value: &mut Value,
1856 refs: &std::collections::BTreeMap<String, Value>,
1857) -> Result<(), String> {
1858 match value {
1859 Value::Array(items) => {
1860 for item in items {
1861 replace_image_upload_placeholders(item, refs)?;
1862 }
1863 }
1864 Value::Object(object) => {
1865 if object.get("type").and_then(Value::as_str) == Some("image_upload") {
1866 let upload_id = image_upload_part_name(object, "image_upload")?;
1867 let replacement = refs
1868 .get(upload_id)
1869 .ok_or_else(|| format!("missing blob replacement for {upload_id}"))?;
1870 *value = replacement.clone();
1871 } else {
1872 for child in object.values_mut() {
1873 replace_image_upload_placeholders(child, refs)?;
1874 }
1875 }
1876 }
1877 _ => {}
1878 }
1879 Ok(())
1880}
1881
1882fn response_value(id: Value, result: Option<Value>, error: Option<JsonRpcError>) -> Value {
1883 serde_json::to_value(JsonRpcResponse {
1884 jsonrpc: JSONRPC_VERSION.to_string(),
1885 id,
1886 result,
1887 error,
1888 })
1889 .unwrap_or_else(|_| {
1890 serde_json::json!({
1891 "jsonrpc": JSONRPC_VERSION,
1892 "id": Value::Null,
1893 "error": {
1894 "code": -32603,
1895 "message": "serialization failed",
1896 }
1897 })
1898 })
1899}
1900
1901fn invalid_params(id: Value, message: impl Into<String>) -> Value {
1902 response_value(
1903 id,
1904 None,
1905 Some(JsonRpcError {
1906 code: -32602,
1907 message: message.into(),
1908 data: None,
1909 }),
1910 )
1911}
1912
1913async fn member_entry_to_console_json(
1914 runtime: &MobRuntime,
1915 entry: &meerkat_mob::runtime::MobMemberListEntry,
1916) -> Value {
1917 let mut value = member_entry_to_json(entry);
1918 if let Some(object) = value.as_object_mut() {
1919 object.insert(
1920 "model_capabilities".to_string(),
1921 serde_json::to_value(model_capabilities_for_member_entry(
1922 runtime.handle().definition(),
1923 entry,
1924 ))
1925 .unwrap_or(Value::Null),
1926 );
1927 }
1928 value
1929}
1930
1931fn internal_error(id: Value, message: impl Into<String>) -> Value {
1932 response_value(
1933 id,
1934 None,
1935 Some(JsonRpcError {
1936 code: -32000,
1937 message: message.into(),
1938 data: None,
1939 }),
1940 )
1941}
1942
1943fn stale_event_cursor_response(id: Value, after_cursor: u64, latest_cursor: u64) -> Value {
1948 response_value(
1949 id,
1950 None,
1951 Some(JsonRpcError {
1952 code: crate::rpc::MOB_EVENTS_STALE_CURSOR_CODE,
1953 message: format!(
1954 "stale mob event cursor: requested {after_cursor}, latest {latest_cursor}"
1955 ),
1956 data: Some(serde_json::json!({
1957 "error": "event_query_stale",
1958 "after_cursor": after_cursor,
1959 "latest_cursor": latest_cursor,
1960 })),
1961 }),
1962 )
1963}
1964
1965fn console_timeline_replay_unavailable_response(
1966 id: Value,
1967 err: ConsoleLogError,
1968 requested_cursor: Option<&ConsoleCursor>,
1969 latest_cursor: Option<ConsoleCursor>,
1970) -> Value {
1971 response_value(
1972 id,
1973 None,
1974 Some(JsonRpcError {
1975 code: crate::rpc::CONSOLE_TIMELINE_REPLAY_UNAVAILABLE_CODE,
1976 message: format!("query_timeline failed: {err}"),
1977 data: Some(json!({
1978 "error": "replay_unavailable",
1979 "stream": "timeline",
1980 "requested_cursor": requested_cursor.map(ToString::to_string),
1981 "latest_cursor": latest_cursor.map(|cursor| cursor.to_string()),
1982 })),
1983 }),
1984 )
1985}
1986
1987fn parse_console_helper_options(
1988 options_val: Option<&Value>,
1989) -> Result<meerkat_mob::HelperOptions, String> {
1990 crate::rpc::mob_methods::parse_helper_options(options_val)
1991}
1992
1993fn member_is_addressable(member: &meerkat_mob::runtime::MobMemberListEntry) -> bool {
1994 member
1995 .labels
1996 .get("addressable")
1997 .map(|value: &String| !value.eq_ignore_ascii_case("false"))
1998 .unwrap_or(true)
1999}
2000
2001fn member_addressability(member: &meerkat_mob::runtime::MobMemberListEntry) -> &'static str {
2002 if member_is_addressable(member) {
2003 "addressable"
2004 } else {
2005 "internal_only"
2006 }
2007}
2008
2009fn console_identity_status_json_for_identity(
2010 identity: &str,
2011 member: &meerkat_mob::runtime::MobMemberListEntry,
2012 session_id: Option<String>,
2013 response_phase: Option<String>,
2014) -> Value {
2015 json!({
2016 "identity": identity,
2017 "state": member.state,
2018 "role": member.role.to_string(),
2019 "addressability": member_addressability(member),
2020 "display_name": member.labels.get("display_name"),
2021 "labels": member.labels,
2022 "agent_runtime_id": member.agent_identity.to_string(),
2023 "session_id": session_id,
2024 "generation": Value::Null,
2025 "checkpoint_version": Value::Null,
2026 "continuity_health": Value::Null,
2027 "lease_healthy": Value::Null,
2028 "lease": Value::Null,
2029 "response_phase": response_phase,
2030 })
2031}
2032
2033fn console_identity_inspect_json_for_identity(
2034 identity: &str,
2035 member: &meerkat_mob::runtime::MobMemberListEntry,
2036 session_id: Option<String>,
2037 response_phase: Option<String>,
2038) -> Value {
2039 let peers: Vec<String> = member.wired_to.iter().map(ToString::to_string).collect();
2040 json!({
2041 "identity": identity,
2042 "state": member.state,
2043 "role": member.role.to_string(),
2044 "addressability": member_addressability(member),
2045 "display_name": member.labels.get("display_name"),
2046 "labels": member.labels,
2047 "continuity_health": Value::Null,
2048 "lease_healthy": Value::Null,
2049 "lease": Value::Null,
2050 "continuity": {
2051 "generation": Value::Null,
2052 "checkpoint_version": Value::Null,
2053 "session_id": session_id,
2054 "agent_runtime_id": member.agent_identity.to_string(),
2055 },
2056 "topology_peers": peers,
2057 "output_preview": Value::Null,
2058 "response_phase": response_phase,
2059 })
2060}
2061
2062async fn lookup_member_with_session(
2066 handle: &MobHandle,
2067 identity: &MeerkatId,
2068) -> Option<(meerkat_mob::runtime::MobMemberListEntry, Option<String>)> {
2069 let entries = handle.list_members_including_retiring().await;
2070 let entry = entries
2071 .into_iter()
2072 .find(|e| &e.agent_identity == identity)?;
2073 let session_id = handle
2074 .resolve_bridge_session_id_observation(identity)
2075 .await
2076 .map(|s| s.to_string());
2077 Some((entry, session_id))
2078}
2079
2080#[derive(Debug, Clone)]
2081struct ConsoleRuntimeIdentityAlias {
2082 identity: String,
2083 runtime_member_id: String,
2084 member: meerkat_mob::runtime::MobMemberListEntry,
2085 session_id: Option<String>,
2086}
2087
2088fn durable_identity_for_member(member: &meerkat_mob::runtime::MobMemberListEntry) -> String {
2089 member
2090 .labels
2091 .get("agent_identity")
2092 .filter(|value| !value.trim().is_empty())
2093 .cloned()
2094 .unwrap_or_else(|| member.agent_identity.to_string())
2095}
2096
2097async fn lookup_member_alias_with_session(
2098 handle: &MobHandle,
2099 visibility_policy: &dyn ConsoleVisibilityPolicy,
2100 requested_identity: &str,
2101) -> Result<Option<ConsoleRuntimeIdentityAlias>, JsonRpcError> {
2102 let all_matches = lookup_member_alias_candidates_with_session(handle, requested_identity).await;
2103 let mut visible_matches = Vec::new();
2104 for alias in &all_matches {
2105 if runtime_alias_visible_to_console(handle, visibility_policy, alias) {
2106 visible_matches.push(alias.clone());
2107 }
2108 }
2109 let member = if visible_matches.len() > 1 {
2110 return Err(ambiguous_live_identity_alias_error(
2111 requested_identity,
2112 &visible_matches
2113 .iter()
2114 .map(|alias| alias.runtime_member_id.clone())
2115 .collect::<Vec<_>>(),
2116 ));
2117 } else if let Some(alias) = visible_matches.into_iter().next() {
2118 Some(alias)
2119 } else {
2120 all_matches.into_iter().next()
2121 };
2122 Ok(member)
2123}
2124
2125async fn lookup_visible_member_alias_candidates_with_session(
2126 handle: &MobHandle,
2127 visibility_policy: &dyn ConsoleVisibilityPolicy,
2128 requested_identity: &str,
2129) -> Vec<ConsoleRuntimeIdentityAlias> {
2130 let mut visible = Vec::new();
2131 for alias in lookup_member_alias_candidates_with_session(handle, requested_identity).await {
2132 if runtime_alias_visible_to_console(handle, visibility_policy, &alias) {
2133 visible.push(alias);
2134 }
2135 }
2136 visible
2137}
2138
2139async fn lookup_member_alias_candidates_with_session(
2140 handle: &MobHandle,
2141 requested_identity: &str,
2142) -> Vec<ConsoleRuntimeIdentityAlias> {
2143 let requested_member_id = MeerkatId::from(requested_identity);
2144 let entries = handle.list_members_including_retiring().await;
2145 let exact_matches = entries
2146 .iter()
2147 .filter(|entry| entry.agent_identity == requested_member_id)
2148 .cloned()
2149 .collect::<Vec<_>>();
2150 let label_matches = entries
2151 .iter()
2152 .filter(|entry| {
2153 entry
2154 .labels
2155 .get("agent_identity")
2156 .is_some_and(|identity| identity == requested_identity)
2157 })
2158 .cloned()
2159 .collect::<Vec<_>>();
2160 let mut matches = exact_matches;
2161 matches.extend(label_matches);
2162 let mut seen_member_ids = BTreeSet::new();
2163 matches.retain(|entry| seen_member_ids.insert(entry.agent_identity.to_string()));
2164 let mut aliases = Vec::with_capacity(matches.len());
2165 for member in matches {
2166 let runtime_member_id = member.agent_identity.to_string();
2167 let identity = durable_identity_for_member(&member);
2168 let session_id = handle
2169 .resolve_bridge_session_id_observation(&member.agent_identity)
2170 .await
2171 .map(|s| s.to_string());
2172 aliases.push(ConsoleRuntimeIdentityAlias {
2173 identity,
2174 runtime_member_id,
2175 member,
2176 session_id,
2177 });
2178 }
2179 aliases
2180}
2181
2182async fn reject_ambiguous_projected_live_identity(
2183 handle: &MobHandle,
2184 visibility_policy: &dyn ConsoleVisibilityPolicy,
2185 alias: &ConsoleRuntimeIdentityAlias,
2186) -> Result<(), JsonRpcError> {
2187 let candidates = lookup_visible_member_alias_candidates_with_session(
2188 handle,
2189 visibility_policy,
2190 &alias.identity,
2191 )
2192 .await;
2193 if candidates.len() > 1 {
2194 return Err(ambiguous_live_identity_alias_error(
2195 &alias.identity,
2196 &candidates
2197 .iter()
2198 .map(|candidate| candidate.runtime_member_id.clone())
2199 .collect::<Vec<_>>(),
2200 ));
2201 }
2202 Ok(())
2203}
2204
2205fn ambiguous_live_identity_alias_error(
2206 requested_identity: &str,
2207 candidates: &[String],
2208) -> JsonRpcError {
2209 JsonRpcError {
2210 code: -32602,
2211 message: format!(
2212 "ambiguous live identity alias {requested_identity}: candidates [{}]",
2213 candidates.join(", ")
2214 ),
2215 data: Some(json!({
2216 "kind": "ambiguous_live_identity_alias",
2217 "identity": requested_identity,
2218 "candidates": candidates,
2219 })),
2220 }
2221}
2222
2223async fn lookup_member_runtime_alias_with_session(
2224 handle: &MobHandle,
2225 runtime_member_id: &str,
2226) -> Option<ConsoleRuntimeIdentityAlias> {
2227 let requested_member_id = MeerkatId::from(runtime_member_id);
2228 let entries = handle.list_members_including_retiring().await;
2229 let member = entries
2230 .into_iter()
2231 .find(|entry| entry.agent_identity == requested_member_id)?;
2232 let runtime_member_id = member.agent_identity.to_string();
2233 let identity = durable_identity_for_member(&member);
2234 let session_id = handle
2235 .resolve_bridge_session_id_observation(&member.agent_identity)
2236 .await
2237 .map(|s| s.to_string());
2238 Some(ConsoleRuntimeIdentityAlias {
2239 identity,
2240 runtime_member_id,
2241 member,
2242 session_id,
2243 })
2244}
2245
2246async fn identity_runtime_alias(
2247 identity_runtime: &crate::identity_first::IdentityRuntime,
2248 requested_identity: &str,
2249) -> Result<Option<(crate::identity_first::AgentIdentity, bool)>, String> {
2250 if requested_identity.starts_with("rt:") {
2251 for status in identity_runtime.statuses().await {
2252 if status
2253 .agent_runtime_id
2254 .as_ref()
2255 .is_some_and(|runtime_id| runtime_id.as_str() == requested_identity)
2256 {
2257 return Ok(Some((status.identity, false)));
2258 }
2259 }
2260 return Ok(None);
2261 }
2262 if let Ok(identity) = crate::identity_first::AgentIdentity::parse(requested_identity) {
2263 match identity_runtime.status(&identity).await {
2264 Ok(_) => return Ok(Some((identity, true))),
2265 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
2266 Err(err) => return Err(err.to_string()),
2267 }
2268 }
2269
2270 for status in identity_runtime.statuses().await {
2271 if status
2272 .agent_runtime_id
2273 .as_ref()
2274 .is_some_and(|runtime_id| runtime_id.as_str() == requested_identity)
2275 {
2276 return Ok(Some((status.identity, false)));
2277 }
2278 }
2279 Ok(None)
2280}
2281
2282async fn resolve_console_identity_control_target(
2283 handle: &MobHandle,
2284 identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
2285 visibility_policy: &dyn ConsoleVisibilityPolicy,
2286 requested_identity: &str,
2287) -> Result<
2288 Option<(
2289 crate::identity_first::AgentIdentity,
2290 bool,
2291 Option<ConsoleRuntimeIdentityAlias>,
2292 )>,
2293 JsonRpcError,
2294> {
2295 if let Some(identity_runtime) = identity_runtime {
2296 match identity_runtime_alias(identity_runtime, requested_identity).await {
2297 Ok(Some((identity, exact))) => {
2298 let live = if exact {
2299 let registered_live = match identity_runtime.status(&identity).await {
2300 Ok(status) => match status.agent_runtime_id.as_ref() {
2301 Some(runtime_id) => {
2302 lookup_member_runtime_alias_with_session(
2303 handle,
2304 runtime_id.as_str(),
2305 )
2306 .await
2307 }
2308 None => None,
2309 },
2310 Err(_) => None,
2311 };
2312 if let Some(alias) = registered_live.as_ref()
2313 && !runtime_alias_visible_to_console(handle, visibility_policy, alias)
2314 {
2315 return Err(identity_hidden_by_policy_error(requested_identity));
2316 }
2317 if let Some(registered) = registered_live {
2318 return Ok(Some((identity, exact, Some(registered))));
2319 }
2320 let requested_live_candidates =
2321 lookup_visible_member_alias_candidates_with_session(
2322 handle,
2323 visibility_policy,
2324 requested_identity,
2325 )
2326 .await;
2327 let requested_live = if requested_live_candidates.len() > 1 {
2328 return Err(ambiguous_live_identity_alias_error(
2329 requested_identity,
2330 &requested_live_candidates
2331 .iter()
2332 .map(|alias| alias.runtime_member_id.clone())
2333 .collect::<Vec<_>>(),
2334 ));
2335 } else {
2336 requested_live_candidates.into_iter().next()
2337 };
2338 match (registered_live, requested_live) {
2339 (Some(registered), Some(requested))
2340 if registered.runtime_member_id == requested.runtime_member_id =>
2341 {
2342 Some(registered)
2343 }
2344 (Some(registered), None) => Some(registered),
2345 (Some(_registered), Some(requested)) => Some(requested),
2346 (None, requested) => requested,
2347 }
2348 } else {
2349 let registered_live =
2350 lookup_member_runtime_alias_with_session(handle, requested_identity).await;
2351 if let Some(alias) = registered_live.as_ref()
2352 && !runtime_alias_visible_to_console(handle, visibility_policy, alias)
2353 {
2354 return Err(identity_hidden_by_policy_error(requested_identity));
2355 }
2356 if let Some(registered) = registered_live {
2357 return Ok(Some((identity, exact, Some(registered))));
2358 }
2359 let durable_live_candidates =
2360 lookup_visible_member_alias_candidates_with_session(
2361 handle,
2362 visibility_policy,
2363 identity.as_str(),
2364 )
2365 .await;
2366 let durable_live = if durable_live_candidates.len() > 1 {
2367 return Err(ambiguous_live_identity_alias_error(
2368 identity.as_str(),
2369 &durable_live_candidates
2370 .iter()
2371 .map(|alias| alias.runtime_member_id.clone())
2372 .collect::<Vec<_>>(),
2373 ));
2374 } else {
2375 durable_live_candidates.into_iter().next()
2376 };
2377 match (registered_live, durable_live) {
2378 (Some(registered), Some(durable))
2379 if registered.runtime_member_id == durable.runtime_member_id =>
2380 {
2381 Some(registered)
2382 }
2383 (Some(registered), None) => Some(registered),
2384 (Some(_registered), Some(durable)) => Some(durable),
2385 (None, durable) => durable,
2386 }
2387 };
2388 return Ok(Some((identity, exact, live)));
2389 }
2390 Ok(None) => {}
2391 Err(err) => {
2392 return Err(JsonRpcError {
2393 code: -32000,
2394 message: err,
2395 data: None,
2396 });
2397 }
2398 }
2399 }
2400
2401 let live_alias =
2402 lookup_member_alias_with_session(handle, visibility_policy, requested_identity).await?;
2403 let Some(alias) = live_alias else {
2404 return Ok(None);
2405 };
2406 if let Some(identity_runtime) = identity_runtime
2407 && let Some(bound_status) = identity_runtime
2408 .statuses()
2409 .await
2410 .into_iter()
2411 .find(|status| {
2412 status
2413 .agent_runtime_id
2414 .as_ref()
2415 .is_some_and(|runtime_id| runtime_id.as_str() == alias.runtime_member_id)
2416 })
2417 && bound_status.identity.as_str() != alias.identity
2418 {
2419 return Err(JsonRpcError {
2420 code: -32000,
2421 message: format!(
2422 "stale live identity alias: live console alias {} resolves to {}, but identity runtime binding belongs to {}",
2423 alias.identity,
2424 alias.runtime_member_id,
2425 bound_status.identity.as_str(),
2426 ),
2427 data: Some(json!({
2428 "kind": "stale_live_identity_alias",
2429 "identity": alias.identity,
2430 "runtime_member_id": alias.runtime_member_id,
2431 "registered_identity": bound_status.identity.as_str(),
2432 })),
2433 });
2434 }
2435 let identity = crate::identity_first::AgentIdentity::parse(&alias.identity).map_err(|err| {
2436 JsonRpcError {
2437 code: -32602,
2438 message: format!("invalid identity: {err}"),
2439 data: None,
2440 }
2441 })?;
2442 let durable_live_candidates = lookup_visible_member_alias_candidates_with_session(
2443 handle,
2444 visibility_policy,
2445 identity.as_str(),
2446 )
2447 .await;
2448 if durable_live_candidates.len() > 1 {
2449 return Err(ambiguous_live_identity_alias_error(
2450 identity.as_str(),
2451 &durable_live_candidates
2452 .iter()
2453 .map(|alias| alias.runtime_member_id.clone())
2454 .collect::<Vec<_>>(),
2455 ));
2456 }
2457 Ok(Some((identity, false, Some(alias))))
2458}
2459
2460fn live_alias_matches_status_runtime(
2461 alias: Option<&ConsoleRuntimeIdentityAlias>,
2462 status: &crate::identity_first::IdentityStatus,
2463) -> bool {
2464 let Some(alias) = alias else {
2465 return true;
2466 };
2467 let session_matches = match (
2468 status.session_id.as_ref().map(ToString::to_string),
2469 alias.session_id.as_deref(),
2470 ) {
2471 (Some(status_session), Some(live_session)) => status_session == live_session,
2472 _ => true,
2473 };
2474 status
2475 .agent_runtime_id
2476 .as_ref()
2477 .is_some_and(|runtime_id| runtime_id.as_str() == alias.runtime_member_id)
2478 && alias.identity == status.identity.as_str()
2479 && session_matches
2480}
2481
2482async fn stale_live_alias_json_rpc_error(
2483 operation: &str,
2484 identity_runtime: &crate::identity_first::IdentityRuntime,
2485 identity: &crate::identity_first::AgentIdentity,
2486 live_alias: Option<&ConsoleRuntimeIdentityAlias>,
2487) -> Option<JsonRpcError> {
2488 let live_alias = live_alias?;
2489 let Ok(status) = identity_runtime.status(identity).await else {
2490 return None;
2491 };
2492 if live_alias_matches_status_runtime(Some(live_alias), &status) {
2493 return None;
2494 }
2495 let registered_runtime_member_id = status
2496 .agent_runtime_id
2497 .as_ref()
2498 .map(crate::identity_first::AgentRuntimeId::as_str);
2499 Some(JsonRpcError {
2500 code: -32000,
2501 message: format!(
2502 "{operation} failed: identity runtime binding for {} points at {}, but requested live member is {}",
2503 identity.as_str(),
2504 registered_runtime_member_id.unwrap_or("<none>"),
2505 live_alias.runtime_member_id
2506 ),
2507 data: Some(json!({
2508 "kind": "stale_identity_runtime_binding",
2509 "identity": identity.as_str(),
2510 "registered_runtime_member_id": registered_runtime_member_id,
2511 "live_runtime_member_id": live_alias.runtime_member_id,
2512 "registered_session_id": status.session_id.as_ref().map(ToString::to_string),
2513 "live_session_id": live_alias.session_id,
2514 })),
2515 })
2516}
2517
2518fn reset_requires_session_bridge_json_rpc_error() -> JsonRpcError {
2519 JsonRpcError {
2520 code: -32602,
2521 message: "reset requires an identity runtime with a session bridge".to_string(),
2522 data: Some(json!({
2523 "kind": "identity_reset_requires_session_bridge",
2524 })),
2525 }
2526}
2527
2528fn console_identity_status_json_from_record(
2529 record: &crate::console_aggregator::ConsoleIdentityRecord,
2530 response_phase: Option<String>,
2531) -> Value {
2532 json!({
2533 "identity": record.identity,
2534 "state": record.health,
2535 "role": record.labels.get("role"),
2536 "addressability": if record.addressable { "addressable" } else { "internal_only" },
2537 "display_name": record.display_name,
2538 "labels": record.labels,
2539 "agent_runtime_id": record.runtime_member_id,
2540 "session_id": record.session_id,
2541 "generation": Value::Null,
2542 "checkpoint_version": Value::Null,
2543 "continuity_health": Value::Null,
2544 "lease_healthy": Value::Null,
2545 "lease": Value::Null,
2546 "response_phase": response_phase,
2547 })
2548}
2549
2550fn console_addressability_json(
2551 addressability: crate::identity_first::AgentAddressability,
2552) -> &'static str {
2553 match addressability {
2554 crate::identity_first::AgentAddressability::Addressable => "addressable",
2555 crate::identity_first::AgentAddressability::InternalOnly => "internal_only",
2556 }
2557}
2558
2559fn console_identity_record_from_identity_status(
2560 status: &crate::identity_first::IdentityStatus,
2561) -> ConsoleIdentityRecord {
2562 let mut labels = status.labels.clone();
2563 if let Some(profile) = status.profile.as_ref() {
2564 labels
2565 .entry("role".to_string())
2566 .or_insert_with(|| profile.as_str().to_string());
2567 }
2568 let runtime_member_id = status
2569 .agent_runtime_id
2570 .as_ref()
2571 .map(crate::identity_first::AgentRuntimeId::as_str)
2572 .unwrap_or_else(|| status.identity.as_str())
2573 .to_string();
2574 let addressable = status.addressability
2575 == crate::identity_first::AgentAddressability::Addressable
2576 && matches!(
2577 status.state,
2578 crate::identity_first::IdentityLifecycleState::Active
2579 | crate::identity_first::IdentityLifecycleState::Dormant
2580 | crate::identity_first::IdentityLifecycleState::Uninitialized
2581 );
2582 let visibility = match status.state {
2583 crate::identity_first::IdentityLifecycleState::Retiring => {
2584 ConsoleVisibility::RetiredReadable
2585 }
2586 crate::identity_first::IdentityLifecycleState::Broken
2587 | crate::identity_first::IdentityLifecycleState::Suspended => {
2588 ConsoleVisibility::Unreachable
2589 }
2590 _ if addressable => ConsoleVisibility::Addressable,
2591 _ => ConsoleVisibility::Hidden,
2592 };
2593 let health = match status.state {
2594 crate::identity_first::IdentityLifecycleState::Active => "ready",
2595 crate::identity_first::IdentityLifecycleState::Dormant => "dormant",
2596 crate::identity_first::IdentityLifecycleState::Uninitialized => "uninitialized",
2597 crate::identity_first::IdentityLifecycleState::Broken => "broken",
2598 crate::identity_first::IdentityLifecycleState::Suspended => "suspended",
2599 crate::identity_first::IdentityLifecycleState::Retiring => "retired",
2600 }
2601 .to_string();
2602 ConsoleIdentityRecord {
2603 identity: status.identity.as_str().to_string(),
2604 display_name: status
2605 .display_name
2606 .as_ref()
2607 .map(crate::identity_first::DisplayName::as_str)
2608 .unwrap_or_else(|| status.identity.as_str())
2609 .to_string(),
2610 runtime_key: "identity-first".to_string(),
2611 runtime_member_id,
2612 session_id: status.session_id.as_ref().map(ToString::to_string),
2613 visibility,
2614 addressable,
2615 health,
2616 topology_peers: Vec::new(),
2617 labels,
2618 }
2619}
2620
2621fn identity_hidden_by_policy_response(response_id: Value, identity: &str) -> Value {
2622 response_value(
2623 response_id,
2624 None,
2625 Some(identity_hidden_by_policy_error(identity)),
2626 )
2627}
2628
2629fn identity_hidden_by_policy_error(identity: &str) -> JsonRpcError {
2630 JsonRpcError {
2631 code: -32001,
2632 message: format!("unknown identity: {identity}"),
2633 data: Some(json!({
2634 "kind": "identity_hidden_by_policy",
2635 "identity": identity,
2636 })),
2637 }
2638}
2639
2640fn identity_status_visible_to_console(
2641 visibility_policy: &dyn ConsoleVisibilityPolicy,
2642 status: &crate::identity_first::IdentityStatus,
2643) -> bool {
2644 visibility_policy.identity_visible(&console_identity_record_from_identity_status(status))
2645}
2646
2647fn console_member_from_runtime_alias(
2648 handle: &MobHandle,
2649 alias: &ConsoleRuntimeIdentityAlias,
2650) -> ConsoleMember {
2651 ConsoleMember {
2652 agent_identity: alias.runtime_member_id.clone(),
2653 role: alias.member.role.to_string(),
2654 state: match alias.member.state {
2655 meerkat_mob::MemberState::Active => MEMBER_STATE_ACTIVE.to_string(),
2656 meerkat_mob::MemberState::Retiring => MEMBER_STATE_RETIRING.to_string(),
2657 },
2658 model_capabilities: model_capabilities_for_member_entry(handle.definition(), &alias.member),
2659 runtime_mode: Some(alias.member.runtime_mode.to_string()),
2660 session_id: alias.session_id.clone(),
2661 wired_to: alias
2662 .member
2663 .wired_to
2664 .iter()
2665 .map(ToString::to_string)
2666 .collect(),
2667 labels: alias.member.labels.clone(),
2668 }
2669}
2670
2671fn console_identity_record_from_runtime_alias(
2672 alias: &ConsoleRuntimeIdentityAlias,
2673) -> ConsoleIdentityRecord {
2674 let addressable = alias
2675 .member
2676 .labels
2677 .get("addressable")
2678 .map(|value| !value.eq_ignore_ascii_case("false"))
2679 .unwrap_or(true)
2680 && alias.member.state == meerkat_mob::MemberState::Active;
2681 let visibility = match alias.member.state {
2682 meerkat_mob::MemberState::Retiring => ConsoleVisibility::RetiredReadable,
2683 meerkat_mob::MemberState::Active if addressable => ConsoleVisibility::Addressable,
2684 meerkat_mob::MemberState::Active => ConsoleVisibility::Hidden,
2685 };
2686 ConsoleIdentityRecord {
2687 identity: alias.identity.clone(),
2688 display_name: alias
2689 .member
2690 .labels
2691 .get("display_name")
2692 .cloned()
2693 .unwrap_or_else(|| alias.identity.clone()),
2694 runtime_key: "runtime".to_string(),
2695 runtime_member_id: alias.runtime_member_id.clone(),
2696 session_id: alias.session_id.clone(),
2697 visibility,
2698 addressable,
2699 health: match alias.member.state {
2700 meerkat_mob::MemberState::Active => "ready",
2701 meerkat_mob::MemberState::Retiring => "retired",
2702 }
2703 .to_string(),
2704 topology_peers: alias
2705 .member
2706 .wired_to
2707 .iter()
2708 .map(ToString::to_string)
2709 .collect(),
2710 labels: alias.member.labels.clone(),
2711 }
2712}
2713
2714fn runtime_alias_visible_to_console(
2715 handle: &MobHandle,
2716 visibility_policy: &dyn ConsoleVisibilityPolicy,
2717 alias: &ConsoleRuntimeIdentityAlias,
2718) -> bool {
2719 let member = console_member_from_runtime_alias(handle, alias);
2720 if !visibility_policy.member_visible(&member) {
2721 return false;
2722 }
2723 visibility_policy.identity_visible(&console_identity_record_from_runtime_alias(alias))
2724}
2725
2726fn console_identity_status_json_from_identity_status(
2727 status: &crate::identity_first::IdentityStatus,
2728 response_phase: Option<String>,
2729) -> Value {
2730 json!({
2731 "identity": status.identity.as_str(),
2732 "state": format!("{:?}", status.state),
2733 "role": status.profile.as_ref().map(ProfileName::as_str),
2734 "addressability": console_addressability_json(status.addressability),
2735 "display_name": status.display_name.as_ref().map(crate::identity_first::DisplayName::as_str),
2736 "labels": status.labels,
2737 "agent_runtime_id": status.agent_runtime_id.as_ref().map(crate::identity_first::AgentRuntimeId::as_str),
2738 "session_id": status.session_id.as_ref().map(ToString::to_string),
2739 "generation": status.generation.map(crate::identity_first::ContinuityGeneration::get),
2740 "checkpoint_version": status.checkpoint_version.map(crate::identity_first::CheckpointVersion::get),
2741 "continuity_health": status.continuity_health,
2742 "lease_healthy": status.lease.as_ref().map(|lease| lease.healthy),
2743 "lease": status.lease.as_ref().map(|lease| json!({
2744 "fencing_token": lease.fencing_token.get(),
2745 "ttl_remaining_ms": lease.ttl_remaining.as_millis() as u64,
2746 "healthy": lease.healthy,
2747 })),
2748 "response_phase": response_phase,
2749 })
2750}
2751
2752fn console_identity_inspect_json_from_identity_status(
2753 status: &crate::identity_first::IdentityStatus,
2754 live_alias: Option<&ConsoleRuntimeIdentityAlias>,
2755 response_phase: Option<String>,
2756) -> Value {
2757 let topology_peers = live_alias
2758 .map(|alias| {
2759 alias
2760 .member
2761 .wired_to
2762 .iter()
2763 .map(ToString::to_string)
2764 .map(Value::String)
2765 .collect::<Vec<_>>()
2766 })
2767 .unwrap_or_default();
2768 let session_id = status
2769 .session_id
2770 .as_ref()
2771 .map(ToString::to_string)
2772 .or_else(|| live_alias.and_then(|alias| alias.session_id.clone()));
2773 let agent_runtime_id = status
2774 .agent_runtime_id
2775 .as_ref()
2776 .map(crate::identity_first::AgentRuntimeId::as_str)
2777 .map(ToString::to_string)
2778 .or_else(|| live_alias.map(|alias| alias.runtime_member_id.clone()));
2779 json!({
2780 "identity": status.identity.as_str(),
2781 "state": format!("{:?}", status.state),
2782 "role": status.profile.as_ref().map(ProfileName::as_str),
2783 "addressability": console_addressability_json(status.addressability),
2784 "display_name": status.display_name.as_ref().map(crate::identity_first::DisplayName::as_str),
2785 "labels": status.labels,
2786 "continuity_health": status.continuity_health,
2787 "lease_healthy": status.lease.as_ref().map(|lease| lease.healthy),
2788 "lease": status.lease.as_ref().map(|lease| json!({
2789 "fencing_token": lease.fencing_token.get(),
2790 "ttl_remaining_ms": lease.ttl_remaining.as_millis() as u64,
2791 "healthy": lease.healthy,
2792 })),
2793 "continuity": {
2794 "generation": status.generation.map(crate::identity_first::ContinuityGeneration::get),
2795 "checkpoint_version": status.checkpoint_version.map(crate::identity_first::CheckpointVersion::get),
2796 "session_id": session_id,
2797 "agent_runtime_id": agent_runtime_id,
2798 },
2799 "topology_peers": topology_peers,
2800 "output_preview": Value::Null,
2801 "response_phase": response_phase,
2802 })
2803}
2804
2805fn console_identity_inspect_json_from_record(
2806 inspection: &crate::console_aggregator::ConsoleIdentityInspection,
2807 response_phase: Option<String>,
2808) -> Value {
2809 let record = &inspection.identity;
2810 json!({
2811 "identity": record.identity,
2812 "state": record.health,
2813 "role": record.labels.get("role"),
2814 "addressability": if record.addressable { "addressable" } else { "internal_only" },
2815 "display_name": record.display_name,
2816 "labels": record.labels,
2817 "continuity_health": Value::Null,
2818 "lease_healthy": Value::Null,
2819 "lease": Value::Null,
2820 "continuity": {
2821 "generation": Value::Null,
2822 "checkpoint_version": Value::Null,
2823 "session_id": record.session_id,
2824 "agent_runtime_id": record.runtime_member_id,
2825 },
2826 "topology_peers": inspection.peers,
2827 "output_preview": Value::Null,
2828 "response_phase": response_phase,
2829 })
2830}
2831
2832fn lifecycle_archive_cleanup_completed(error: &str) -> bool {
2833 error.contains("disposal completed but ArchiveSession failed")
2834 && error.contains("cancel-before-retire failed")
2835 && error.contains("Runtime not ready: running")
2836}
2837
2838async fn respawn_console_member(
2839 handle: &MobHandle,
2840 runtime_member_id: &MeerkatId,
2841) -> Result<(), String> {
2842 let entry_before_respawn = handle.get_member(runtime_member_id).await;
2843 match handle.respawn(runtime_member_id.clone(), None).await {
2844 Ok(_receipt) => Ok(()),
2845 Err(err) if lifecycle_archive_cleanup_completed(&err.to_string()) => {
2846 if handle.get_member(runtime_member_id).await.is_none()
2847 && let Some(entry) = entry_before_respawn
2848 {
2849 let mut spec = SpawnMemberSpec::new(entry.role.clone(), runtime_member_id.clone());
2850 if !entry.labels.is_empty() {
2851 spec = spec.with_labels(entry.labels.clone());
2852 }
2853 handle
2854 .ensure_member(spec)
2855 .await
2856 .map_err(|ensure_err| ensure_err.to_string())?;
2857 }
2858 Ok(())
2859 }
2860 Err(err) => Err(err.to_string()),
2861 }
2862}
2863
2864async fn retire_console_member(
2865 handle: &MobHandle,
2866 runtime_member_id: &MeerkatId,
2867) -> Result<(), String> {
2868 match handle.retire(runtime_member_id.clone()).await {
2869 Ok(()) => Ok(()),
2870 Err(err) if lifecycle_archive_cleanup_completed(&err.to_string()) => Ok(()),
2871 Err(err) => Err(err.to_string()),
2872 }
2873}
2874
2875#[cfg(test)]
2876fn member_id_matches_durable_identity(member_id: &str, durable_identity: &str) -> bool {
2877 member_id == durable_identity
2878}
2879
2880async fn retire_stale_console_members_for_identity(
2881 handle: &MobHandle,
2882 visibility_policy: &dyn ConsoleVisibilityPolicy,
2883 durable_identity: &str,
2884 keep_runtime_member_id: Option<&str>,
2885) -> Result<(), String> {
2886 let stale_members = lookup_member_alias_candidates_with_session(handle, durable_identity)
2887 .await
2888 .into_iter()
2889 .filter(|alias| {
2890 runtime_alias_visible_to_console(handle, visibility_policy, alias)
2891 && keep_runtime_member_id
2892 .map(|keep| alias.runtime_member_id != keep)
2893 .unwrap_or(true)
2894 })
2895 .map(|alias| MeerkatId::from(alias.runtime_member_id.as_str()))
2896 .collect::<Vec<_>>();
2897 for member_id in stale_members {
2898 retire_console_member(handle, &member_id).await?;
2899 }
2900 Ok(())
2901}
2902
2903fn console_identity_error_response(
2904 response_id: Value,
2905 operation: &str,
2906 err: crate::identity_first::IdentityRuntimeError,
2907) -> Value {
2908 match err {
2909 crate::identity_first::IdentityRuntimeError::UnknownIdentity(identity) => {
2910 invalid_params(response_id, format!("identity not found: {identity}"))
2911 }
2912 other => internal_error(response_id, format!("{operation} failed: {other}")),
2913 }
2914}
2915
2916#[allow(clippy::too_many_arguments)]
2917async fn handle_console_aggregator_rpc(
2918 console_aggregator: Option<MobKitConsoleAggregator>,
2919 request: JsonRpcRequest,
2920 is_authenticated: bool,
2921) -> Value {
2922 let response_id = request.id.clone().unwrap_or(Value::Null);
2923 match request.method.as_str() {
2924 "mobkit/capabilities" => response_value(
2925 response_id,
2926 Some(json!({
2927 "methods": [
2928 "mobkit/capabilities",
2929 "mobkit/console/list_identities",
2930 "mobkit/console/inspect_identity",
2931 "mobkit/console/query_timeline",
2932 "mobkit/retire",
2933 "mobkit/console/send",
2934 ],
2935 "authenticated": is_authenticated,
2936 "features": {
2937 "console_aggregator": console_aggregator.is_some(),
2938 "multi_runtime_console": console_aggregator.is_some(),
2939 }
2940 })),
2941 None,
2942 ),
2943 "mobkit/console/list_identities" => {
2944 let Some(aggregator) = &console_aggregator else {
2945 return console_aggregator_unavailable(response_id);
2946 };
2947 match aggregator.list_identities().await {
2948 Ok(identities) => {
2949 response_value(response_id, Some(json!({ "identities": identities })), None)
2950 }
2951 Err(err) => internal_error(response_id, format!("list_identities failed: {err}")),
2952 }
2953 }
2954 "mobkit/console/inspect_identity" => {
2955 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
2956 return invalid_params(response_id, "identity required");
2957 };
2958 let Some(aggregator) = &console_aggregator else {
2959 return console_aggregator_unavailable(response_id);
2960 };
2961 match Box::pin(aggregator.inspect_identity(identity)).await {
2962 Ok(Some(inspection)) => response_value(
2963 response_id,
2964 Some(serde_json::to_value(inspection).unwrap_or(Value::Null)),
2965 None,
2966 ),
2967 Ok(None) => response_value(
2968 response_id,
2969 None,
2970 Some(JsonRpcError {
2971 code: -32001,
2972 message: format!("unknown identity: {identity}"),
2973 data: None,
2974 }),
2975 ),
2976 Err(err) => internal_error(response_id, format!("inspect_identity failed: {err}")),
2977 }
2978 }
2979 "mobkit/console/query_timeline" => {
2980 let query: ConsoleTimelineWindowQuery =
2981 match serde_json::from_value(request.params.clone()) {
2982 Ok(query) => query,
2983 Err(err) => {
2984 return invalid_params(response_id, format!("invalid query params: {err}"));
2985 }
2986 };
2987 let Some(aggregator) = &console_aggregator else {
2988 return console_aggregator_unavailable(response_id);
2989 };
2990 match Box::pin(aggregator.query_timeline_windowed(query.clone())).await {
2991 Ok(page) => response_value(
2992 response_id,
2993 Some(serde_json::to_value(page).unwrap_or(Value::Null)),
2994 None,
2995 ),
2996 Err(err) => {
2997 let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
2998 console_timeline_replay_unavailable_response(
2999 response_id,
3000 err,
3001 query.after.as_ref(),
3002 latest_cursor,
3003 )
3004 }
3005 }
3006 }
3007 "mobkit/console/send" => {
3008 let send_request: ConsoleSendRequest =
3009 match serde_json::from_value(request.params.clone()) {
3010 Ok(request) => request,
3011 Err(err) => {
3012 return invalid_params(response_id, format!("invalid send params: {err}"));
3013 }
3014 };
3015 let Some(aggregator) = &console_aggregator else {
3016 return console_aggregator_unavailable(response_id);
3017 };
3018 match Box::pin(aggregator.send(send_request)).await {
3019 Ok(accepted) => response_value(
3020 response_id,
3021 Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
3022 None,
3023 ),
3024 Err(err) => response_value(
3025 response_id,
3026 None,
3027 Some(JsonRpcError {
3028 code: console_send_rpc_code(&err),
3029 message: err.to_string(),
3030 data: None,
3031 }),
3032 ),
3033 }
3034 }
3035 "mobkit/retire" => {
3036 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
3037 return invalid_params(response_id, "identity required");
3038 };
3039 let Some(aggregator) = &console_aggregator else {
3040 return console_aggregator_unavailable(response_id);
3041 };
3042 match Box::pin(aggregator.retire_identity(identity)).await {
3043 Ok(true) => {
3044 response_value(response_id, Some(json!({ "identity": identity })), None)
3045 }
3046 Ok(false) => response_value(
3047 response_id,
3048 None,
3049 Some(JsonRpcError {
3050 code: -32001,
3051 message: format!("unknown identity: {identity}"),
3052 data: None,
3053 }),
3054 ),
3055 Err(err) => internal_error(response_id, format!("retire failed: {err}")),
3056 }
3057 }
3058 "mobkit/reset_all" => {
3059 let Some(_aggregator) = &console_aggregator else {
3060 return console_aggregator_unavailable(response_id);
3061 };
3062 response_value(
3063 response_id,
3064 None,
3065 Some(JsonRpcError {
3066 code: -32002,
3067 message: "reset_all is not supported on the aggregator-only RPC surface"
3068 .to_string(),
3069 data: Some(json!({
3070 "kind": "unsupported_reset_all_surface",
3071 "reason": "aggregator reset_all cannot preserve baseline identity semantics",
3072 })),
3073 }),
3074 )
3075 }
3076 _ => response_value(
3077 response_id,
3078 None,
3079 Some(JsonRpcError {
3080 code: -32601,
3081 message: "Method not found".to_string(),
3082 data: None,
3083 }),
3084 ),
3085 }
3086}
3087
3088fn console_aggregator_unavailable(response_id: Value) -> Value {
3089 response_value(
3090 response_id,
3091 None,
3092 Some(JsonRpcError {
3093 code: -32004,
3094 message: "console aggregator unavailable".to_string(),
3095 data: None,
3096 }),
3097 )
3098}
3099
3100#[allow(clippy::large_futures, clippy::too_many_arguments)]
3101#[cfg(test)]
3102async fn handle_console_runtime_rpc(
3103 runtime: &MobRuntime,
3104 module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
3105 contact_directory: Option<&ContactDirectory>,
3106 gateway_peer_keys: Option<&crate::auth::peer_keys::GatewayPeerKeys>,
3107 console_events: Option<ConsoleEventStore>,
3108 console_aggregator: Option<MobKitConsoleAggregator>,
3109 identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
3110 metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
3111 mob_events: Option<MobEventsStore>,
3112 request: JsonRpcRequest,
3113 is_authenticated: bool,
3114) -> Value {
3115 handle_console_runtime_rpc_with_visibility(
3116 runtime,
3117 module_runtime,
3118 contact_directory,
3119 gateway_peer_keys,
3120 console_events,
3121 console_aggregator,
3122 identity_runtime,
3123 metadata_table,
3124 mob_events,
3125 &crate::console_aggregator::AllowAllConsoleVisibilityPolicy,
3126 request,
3127 is_authenticated,
3128 )
3129 .await
3130}
3131
3132#[allow(clippy::too_many_arguments)]
3133async fn handle_console_runtime_rpc_with_visibility(
3134 runtime: &MobRuntime,
3135 module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
3136 contact_directory: Option<&ContactDirectory>,
3137 gateway_peer_keys: Option<&crate::auth::peer_keys::GatewayPeerKeys>,
3138 console_events: Option<ConsoleEventStore>,
3139 console_aggregator: Option<MobKitConsoleAggregator>,
3140 identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
3141 metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
3142 mob_events: Option<MobEventsStore>,
3143 visibility_policy: &dyn ConsoleVisibilityPolicy,
3144 request: JsonRpcRequest,
3145 is_authenticated: bool,
3146) -> Value {
3147 let response_id = request.id.clone().unwrap_or(Value::Null);
3148
3149 match request.method.as_str() {
3150 "mobkit/capabilities" => {
3151 let mut methods = vec![
3152 "mobkit/status",
3153 "mobkit/capabilities",
3154 "mobkit/list_members",
3155 "mobkit/get_member",
3156 "mobkit/find_members",
3157 "mobkit/member_status",
3158 "mobkit/collect_completed",
3159 "mobkit/blob/get",
3160 "mobkit/wait_ready",
3161 "mobkit/flow_status",
3162 "mobkit/list_flows",
3163 "mobkit/list_runs",
3164 "mobkit/console/list_identities",
3165 "mobkit/console/inspect_identity",
3166 "mobkit/console/query_timeline",
3167 "mobkit/mob_events/query",
3168 "mobkit/mob_events/subscribe",
3169 "mobkit/cross_mob/peer_info",
3170 "mobkit/cross_mob/directory",
3171 "mobkit/peer_pubkey",
3172 ];
3173 if identity_runtime.is_some() {
3174 methods.extend_from_slice(&[
3175 "mobkit/status_identity",
3176 "mobkit/inspect_identity",
3177 "mobkit/respawn",
3178 "mobkit/reset",
3179 "mobkit/delete_identity",
3180 ]);
3181 } else if console_aggregator.is_some() {
3182 methods.extend_from_slice(&["mobkit/status_identity", "mobkit/inspect_identity"]);
3183 }
3184 if module_runtime.is_some() {
3185 methods.extend_from_slice(&[
3186 "mobkit/routing/routes/list",
3187 "mobkit/delivery/history",
3188 "mobkit/gating/pending",
3189 "mobkit/gating/audit",
3190 "mobkit/gating/decide",
3191 ]);
3192 }
3193 if is_authenticated {
3194 methods.extend_from_slice(&[
3195 "mobkit/retire",
3196 "mobkit/reset_all",
3197 "mobkit/console/send",
3198 "mobkit/blob/upload",
3199 "mobkit/ensure_member",
3200 "mobkit/retire_member",
3201 "mobkit/respawn_member",
3202 "mobkit/force_cancel_member",
3203 "mobkit/cancel_flow",
3204 "mobkit/run_flow",
3205 "mobkit/spawn_helper",
3206 "mobkit/fork_helper",
3207 "mobkit/attach_existing_session",
3208 "mobkit/reconcile_edges",
3209 "mobkit/cross_mob/wire_local",
3210 "mobkit/cross_mob/unwire_local",
3211 ]);
3212 }
3213 if metadata_table.is_some() {
3214 methods.extend_from_slice(&["mobkit/mob_labels/get", "mobkit/run_labels/get"]);
3215 if is_authenticated {
3216 methods.extend_from_slice(&[
3217 "mobkit/mob_labels/set",
3218 "mobkit/mob_labels/delete",
3219 "mobkit/run_labels/set",
3220 "mobkit/run_labels/delete",
3221 ]);
3222 }
3223 }
3224 response_value(
3225 response_id,
3226 Some(serde_json::json!({
3227 "contract_version": crate::rpc::MOBKIT_CONTRACT_VERSION,
3228 "methods": methods,
3229 "loaded_modules": serde_json::json!([]),
3232 "runtime_capabilities": {
3233 "can_send_messages": is_authenticated,
3234 "can_retire_members": is_authenticated,
3235 "can_spawn_members": is_authenticated,
3236 }
3237 })),
3238 None,
3239 )
3240 }
3241 "mobkit/status" => {
3242 let mob_state = runtime.handle().status_observation_snapshot();
3243 response_value(
3244 response_id,
3245 Some(serde_json::json!({
3246 "contract_version": crate::rpc::MOBKIT_CONTRACT_VERSION,
3247 "running": matches!(mob_state, MobState::Creating | MobState::Running),
3248 "loaded_modules": serde_json::json!([]),
3251 })),
3252 None,
3253 )
3254 }
3255 "mobkit/console/list_identities" => {
3256 let Some(aggregator) = &console_aggregator else {
3257 return response_value(
3258 response_id,
3259 None,
3260 Some(JsonRpcError {
3261 code: -32004,
3262 message: "console aggregator unavailable".to_string(),
3263 data: None,
3264 }),
3265 );
3266 };
3267 match aggregator.list_identities().await {
3268 Ok(identities) => {
3269 response_value(response_id, Some(json!({ "identities": identities })), None)
3270 }
3271 Err(err) => internal_error(response_id, format!("list_identities failed: {err}")),
3272 }
3273 }
3274 "mobkit/console/inspect_identity" => {
3275 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
3276 return invalid_params(response_id, "identity required");
3277 };
3278 let Some(aggregator) = &console_aggregator else {
3279 return response_value(
3280 response_id,
3281 None,
3282 Some(JsonRpcError {
3283 code: -32004,
3284 message: "console aggregator unavailable".to_string(),
3285 data: None,
3286 }),
3287 );
3288 };
3289 match Box::pin(aggregator.inspect_identity(identity)).await {
3290 Ok(Some(inspection)) => response_value(
3291 response_id,
3292 Some(serde_json::to_value(inspection).unwrap_or(Value::Null)),
3293 None,
3294 ),
3295 Ok(None) => response_value(
3296 response_id,
3297 None,
3298 Some(JsonRpcError {
3299 code: -32001,
3300 message: format!("unknown identity: {identity}"),
3301 data: None,
3302 }),
3303 ),
3304 Err(err) => internal_error(response_id, format!("inspect_identity failed: {err}")),
3305 }
3306 }
3307 "mobkit/console/query_timeline" => {
3308 let query: ConsoleTimelineWindowQuery =
3309 match serde_json::from_value(request.params.clone()) {
3310 Ok(query) => query,
3311 Err(err) => {
3312 return invalid_params(response_id, format!("invalid query params: {err}"));
3313 }
3314 };
3315 let Some(aggregator) = &console_aggregator else {
3316 return response_value(
3317 response_id,
3318 None,
3319 Some(JsonRpcError {
3320 code: -32004,
3321 message: "console aggregator unavailable".to_string(),
3322 data: None,
3323 }),
3324 );
3325 };
3326 match Box::pin(aggregator.query_timeline_windowed(query.clone())).await {
3327 Ok(page) => response_value(
3328 response_id,
3329 Some(serde_json::to_value(page).unwrap_or(Value::Null)),
3330 None,
3331 ),
3332 Err(err) => {
3333 let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
3334 console_timeline_replay_unavailable_response(
3335 response_id,
3336 err,
3337 query.after.as_ref(),
3338 latest_cursor,
3339 )
3340 }
3341 }
3342 }
3343 "mobkit/console/send" => {
3344 let send_request: ConsoleSendRequest =
3345 match serde_json::from_value(request.params.clone()) {
3346 Ok(request) => request,
3347 Err(err) => {
3348 return invalid_params(response_id, format!("invalid send params: {err}"));
3349 }
3350 };
3351 let Some(aggregator) = &console_aggregator else {
3352 return response_value(
3353 response_id,
3354 None,
3355 Some(JsonRpcError {
3356 code: -32004,
3357 message: "console aggregator unavailable".to_string(),
3358 data: None,
3359 }),
3360 );
3361 };
3362 if let Some(identity_runtime) = &identity_runtime {
3363 return match Box::pin(console_send_with_identity_first_fallback(
3364 aggregator,
3365 identity_runtime.clone(),
3366 console_events.as_ref(),
3367 send_request,
3368 ))
3369 .await
3370 {
3371 Ok(accepted) => response_value(
3372 response_id,
3373 Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
3374 None,
3375 ),
3376 Err(err) => response_value(
3377 response_id,
3378 None,
3379 Some(JsonRpcError {
3380 code: console_send_rpc_code(&err),
3381 message: err.to_string(),
3382 data: None,
3383 }),
3384 ),
3385 };
3386 }
3387 match Box::pin(aggregator.send(send_request)).await {
3388 Ok(accepted) => response_value(
3389 response_id,
3390 Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
3391 None,
3392 ),
3393 Err(err) => response_value(
3394 response_id,
3395 None,
3396 Some(JsonRpcError {
3397 code: console_send_rpc_code(&err),
3398 message: err.to_string(),
3399 data: None,
3400 }),
3401 ),
3402 }
3403 }
3404 "mobkit/blob/get" => {
3405 let Some(blob_id) = request
3406 .params
3407 .get("blob_id")
3408 .or_else(|| request.params.get("id"))
3409 .and_then(Value::as_str)
3410 else {
3411 return invalid_params(response_id, "blob_id required");
3412 };
3413 if !is_valid_blob_id_value(blob_id) {
3414 return invalid_params(response_id, "invalid blob_id");
3415 }
3416 let Some(store) = runtime.binary_blob_store() else {
3417 return internal_error(response_id, "binary blob store unavailable");
3418 };
3419 match store.get_bytes(&meerkat_core::BlobId::from(blob_id)).await {
3420 Ok(payload) => response_value(
3421 response_id,
3422 Some(serde_json::json!({
3423 "blob_id": payload.blob_id,
3424 "media_type": payload.media_type,
3425 "size": payload.size,
3426 "data": base64::engine::general_purpose::STANDARD.encode(payload.data.as_ref()),
3427 })),
3428 None,
3429 ),
3430 Err(meerkat_core::BlobStoreError::NotFound(_)) => response_value(
3431 response_id,
3432 None,
3433 Some(JsonRpcError {
3434 code: -32001,
3435 message: format!("blob not found: {blob_id}"),
3436 data: Some(json!({ "kind": "not_found", "blob_id": blob_id })),
3437 }),
3438 ),
3439 Err(err) => internal_error(response_id, format!("blob get failed: {err}")),
3440 }
3441 }
3442 "mobkit/list_members" => {
3443 let handle = runtime.handle();
3444 let entries = handle.list_members_including_retiring().await;
3445 let mut members = Vec::with_capacity(entries.len());
3446 for entry in &entries {
3447 members.push(member_entry_to_console_json(runtime, entry).await);
3448 }
3449 response_value(response_id, Some(Value::Array(members)), None)
3450 }
3451 "mobkit/get_member" => {
3452 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
3453 return invalid_params(response_id, "member_id required");
3454 };
3455 let handle = runtime.handle();
3456 let identity = MeerkatId::from(member_id);
3457 let entries = handle.list_members_including_retiring().await;
3458 match entries.into_iter().find(|e| e.agent_identity == identity) {
3459 Some(entry) => response_value(
3460 response_id,
3461 Some(member_entry_to_console_json(runtime, &entry).await),
3462 None,
3463 ),
3464 None => invalid_params(response_id, format!("member not found: {member_id}")),
3465 }
3466 }
3467 "mobkit/find_members" => {
3468 let Some(label_key) = request.params.get("label_key").and_then(Value::as_str) else {
3469 return invalid_params(response_id, "label_key required");
3470 };
3471 let Some(label_value) = request.params.get("label_value").and_then(Value::as_str)
3472 else {
3473 return invalid_params(response_id, "label_value required");
3474 };
3475 let handle = runtime.handle();
3476 let filter = MemberFilter {
3477 labels: std::collections::BTreeMap::from([(
3478 label_key.to_string(),
3479 label_value.to_string(),
3480 )]),
3481 role: None,
3482 state: None,
3483 };
3484 let entries = handle.list_members_matching(filter).await;
3485 let mut matches = Vec::with_capacity(entries.len());
3486 for entry in &entries {
3487 matches.push(member_entry_to_console_json(runtime, entry).await);
3488 }
3489 response_value(response_id, Some(Value::Array(matches)), None)
3490 }
3491 "mobkit/status_identity" => {
3492 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
3493 return invalid_params(response_id, "identity required");
3494 };
3495 let handle = runtime.handle();
3496 if let Some(identity_runtime) = &identity_runtime {
3497 let (parsed_identity, _requested_exact_identity, live_alias) =
3498 match resolve_console_identity_control_target(
3499 &handle,
3500 Some(identity_runtime),
3501 visibility_policy,
3502 identity,
3503 )
3504 .await
3505 {
3506 Ok(Some(target)) => target,
3507 Ok(None) => {
3508 return invalid_params(
3509 response_id,
3510 format!("identity not found: {identity}"),
3511 );
3512 }
3513 Err(err) => return response_value(response_id, None, Some(err)),
3514 };
3515 match identity_runtime.status(&parsed_identity).await {
3516 Ok(status) => {
3517 if !identity_status_visible_to_console(visibility_policy, &status) {
3518 return identity_hidden_by_policy_response(response_id, identity);
3519 }
3520 let phase = if let Some(store) = &console_events {
3521 store
3522 .response_phase_for_identity(status.identity.as_str())
3523 .await
3524 } else {
3525 None
3526 };
3527 if let Some(error) = stale_live_alias_json_rpc_error(
3528 "status_identity",
3529 identity_runtime,
3530 &parsed_identity,
3531 live_alias.as_ref(),
3532 )
3533 .await
3534 {
3535 return response_value(response_id, None, Some(error));
3536 }
3537 return response_value(
3538 response_id,
3539 Some(console_identity_status_json_from_identity_status(
3540 &status, phase,
3541 )),
3542 None,
3543 );
3544 }
3545 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
3546 Err(err) => {
3547 return console_identity_error_response(
3548 response_id,
3549 "status_identity",
3550 err,
3551 );
3552 }
3553 }
3554 }
3555 if let Some(aggregator) = &console_aggregator {
3556 return match Box::pin(aggregator.inspect_identity(identity)).await {
3557 Ok(Some(inspection)) => {
3558 let phase = if let Some(store) = &console_events {
3559 store
3560 .response_phase_for_identity(&inspection.identity.identity)
3561 .await
3562 } else {
3563 None
3564 };
3565 response_value(
3566 response_id,
3567 Some(console_identity_status_json_from_record(
3568 &inspection.identity,
3569 phase,
3570 )),
3571 None,
3572 )
3573 }
3574 Ok(None) => response_value(
3575 response_id,
3576 None,
3577 Some(JsonRpcError {
3578 code: -32001,
3579 message: format!("unknown identity: {identity}"),
3580 data: None,
3581 }),
3582 ),
3583 Err(err) => {
3584 internal_error(response_id, format!("status_identity failed: {err}"))
3585 }
3586 };
3587 }
3588 let live_alias = match lookup_member_alias_with_session(
3589 &handle,
3590 visibility_policy,
3591 identity,
3592 )
3593 .await
3594 {
3595 Ok(alias) => alias,
3596 Err(err) => return response_value(response_id, None, Some(err)),
3597 };
3598 let Some(alias) = live_alias else {
3599 return invalid_params(response_id, format!("identity not found: {identity}"));
3600 };
3601 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
3602 return identity_hidden_by_policy_response(response_id, identity);
3603 }
3604 if let Err(err) =
3605 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
3606 {
3607 return response_value(response_id, None, Some(err));
3608 }
3609 let phase = if let Some(store) = &console_events {
3610 store.response_phase_for_identity(&alias.identity).await
3611 } else {
3612 None
3613 };
3614 response_value(
3615 response_id,
3616 Some(console_identity_status_json_for_identity(
3617 &alias.identity,
3618 &alias.member,
3619 alias.session_id,
3620 phase,
3621 )),
3622 None,
3623 )
3624 }
3625 "mobkit/inspect_identity" => {
3626 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
3627 return invalid_params(response_id, "identity required");
3628 };
3629 let handle = runtime.handle();
3630 if let Some(identity_runtime) = &identity_runtime {
3631 let (parsed_identity, _requested_exact_identity, live_alias) =
3632 match resolve_console_identity_control_target(
3633 &handle,
3634 Some(identity_runtime),
3635 visibility_policy,
3636 identity,
3637 )
3638 .await
3639 {
3640 Ok(Some(target)) => target,
3641 Ok(None) => {
3642 return invalid_params(
3643 response_id,
3644 format!("identity not found: {identity}"),
3645 );
3646 }
3647 Err(err) => return response_value(response_id, None, Some(err)),
3648 };
3649 match identity_runtime.status(&parsed_identity).await {
3650 Ok(status) => {
3651 if !identity_status_visible_to_console(visibility_policy, &status) {
3652 return identity_hidden_by_policy_response(response_id, identity);
3653 }
3654 let phase = if let Some(store) = &console_events {
3655 store
3656 .response_phase_for_identity(status.identity.as_str())
3657 .await
3658 } else {
3659 None
3660 };
3661 if let Some(error) = stale_live_alias_json_rpc_error(
3662 "inspect_identity",
3663 identity_runtime,
3664 &parsed_identity,
3665 live_alias.as_ref(),
3666 )
3667 .await
3668 {
3669 return response_value(response_id, None, Some(error));
3670 }
3671 return response_value(
3672 response_id,
3673 Some(console_identity_inspect_json_from_identity_status(
3674 &status,
3675 live_alias.as_ref(),
3676 phase,
3677 )),
3678 None,
3679 );
3680 }
3681 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
3682 Err(err) => {
3683 return console_identity_error_response(
3684 response_id,
3685 "inspect_identity",
3686 err,
3687 );
3688 }
3689 }
3690 }
3691 if let Some(aggregator) = &console_aggregator {
3692 return match Box::pin(aggregator.inspect_identity(identity)).await {
3693 Ok(Some(inspection)) => {
3694 let phase = if let Some(store) = &console_events {
3695 store
3696 .response_phase_for_identity(&inspection.identity.identity)
3697 .await
3698 } else {
3699 None
3700 };
3701 response_value(
3702 response_id,
3703 Some(console_identity_inspect_json_from_record(
3704 &inspection,
3705 phase,
3706 )),
3707 None,
3708 )
3709 }
3710 Ok(None) => response_value(
3711 response_id,
3712 None,
3713 Some(JsonRpcError {
3714 code: -32001,
3715 message: format!("unknown identity: {identity}"),
3716 data: None,
3717 }),
3718 ),
3719 Err(err) => {
3720 internal_error(response_id, format!("inspect_identity failed: {err}"))
3721 }
3722 };
3723 }
3724 let live_alias = match lookup_member_alias_with_session(
3725 &handle,
3726 visibility_policy,
3727 identity,
3728 )
3729 .await
3730 {
3731 Ok(alias) => alias,
3732 Err(err) => return response_value(response_id, None, Some(err)),
3733 };
3734 let Some(alias) = live_alias else {
3735 return invalid_params(response_id, format!("identity not found: {identity}"));
3736 };
3737 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
3738 return identity_hidden_by_policy_response(response_id, identity);
3739 }
3740 if let Err(err) =
3741 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
3742 {
3743 return response_value(response_id, None, Some(err));
3744 }
3745 let phase = if let Some(store) = &console_events {
3746 store.response_phase_for_identity(&alias.identity).await
3747 } else {
3748 None
3749 };
3750 response_value(
3751 response_id,
3752 Some(console_identity_inspect_json_for_identity(
3753 &alias.identity,
3754 &alias.member,
3755 alias.session_id,
3756 phase,
3757 )),
3758 None,
3759 )
3760 }
3761 "mobkit/retire" => {
3762 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
3763 return invalid_params(response_id, "identity required");
3764 };
3765 let handle = runtime.handle();
3766 if let Some(identity_runtime) = &identity_runtime {
3767 let (parsed_identity, _requested_exact_identity, live_alias) =
3768 match resolve_console_identity_control_target(
3769 &handle,
3770 Some(identity_runtime),
3771 visibility_policy,
3772 identity,
3773 )
3774 .await
3775 {
3776 Ok(Some(target)) => target,
3777 Ok(None) => {
3778 return invalid_params(
3779 response_id,
3780 format!("identity not found: {identity}"),
3781 );
3782 }
3783 Err(err) => return response_value(response_id, None, Some(err)),
3784 };
3785 let registered_status = match identity_runtime.status(&parsed_identity).await {
3786 Ok(status) => Some(status),
3787 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => None,
3788 Err(err) => {
3789 return console_identity_error_response(response_id, "retire", err);
3790 }
3791 };
3792 if let Some(status) = registered_status.as_ref() {
3793 if !identity_status_visible_to_console(visibility_policy, status) {
3794 return identity_hidden_by_policy_response(response_id, identity);
3795 }
3796 if let Some(error) = stale_live_alias_json_rpc_error(
3797 "retire",
3798 identity_runtime,
3799 &parsed_identity,
3800 live_alias.as_ref(),
3801 )
3802 .await
3803 {
3804 return response_value(response_id, None, Some(error));
3805 }
3806 }
3807 match identity_runtime.retire(&parsed_identity).await {
3808 Ok(token) => {
3809 let keep_runtime_member_id = registered_status
3810 .as_ref()
3811 .and_then(|status| status.agent_runtime_id.as_ref())
3812 .filter(|_| identity_runtime.has_session_bridge())
3813 .map(crate::identity_first::AgentRuntimeId::as_str);
3814 let cleanup_warning = if registered_status.is_some()
3815 && let Err(err) = retire_stale_console_members_for_identity(
3816 &handle,
3817 visibility_policy,
3818 parsed_identity.as_str(),
3819 keep_runtime_member_id,
3820 )
3821 .await
3822 {
3823 Some(json!({
3824 "kind": "stale_member_cleanup_failed_after_identity_retire",
3825 "identity": parsed_identity.as_str(),
3826 "message": err,
3827 }))
3828 } else {
3829 None
3830 };
3831 if let Some(store) = &console_events {
3832 store
3833 .record_lifecycle(
3834 parsed_identity.as_str(),
3835 "identity_retired",
3836 json!({
3837 "fencing_token": token.get(),
3838 "cleanup_warning": cleanup_warning.clone(),
3839 }),
3840 )
3841 .await;
3842 }
3843 return response_value(
3844 response_id,
3845 Some(json!({
3846 "identity": parsed_identity.as_str(),
3847 "fencing_token": token.get(),
3848 "cleanup_warning": cleanup_warning,
3849 })),
3850 None,
3851 );
3852 }
3853 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
3854 if let Some(alias) = live_alias.as_ref() {
3855 if !runtime_alias_visible_to_console(&handle, visibility_policy, alias)
3856 {
3857 return identity_hidden_by_policy_response(response_id, identity);
3858 }
3859 let mid = MeerkatId::from(alias.runtime_member_id.as_str());
3860 return match retire_console_member(&handle, &mid).await {
3861 Ok(()) => {
3862 if let Some(store) = &console_events {
3863 store
3864 .record_lifecycle(
3865 &alias.identity,
3866 "identity_retired",
3867 json!({}),
3868 )
3869 .await;
3870 }
3871 response_value(
3872 response_id,
3873 Some(json!({ "identity": alias.identity })),
3874 None,
3875 )
3876 }
3877 Err(err) => {
3878 internal_error(response_id, format!("retire failed: {err}"))
3879 }
3880 };
3881 }
3882 }
3883 Err(err) => return console_identity_error_response(response_id, "retire", err),
3884 }
3885 }
3886 if let Some(aggregator) = &console_aggregator {
3887 let canonical_identity = match Box::pin(aggregator.inspect_identity(identity)).await
3888 {
3889 Ok(Some(inspection)) => inspection.identity.identity,
3890 Ok(None) => identity.to_string(),
3891 Err(_) => identity.to_string(),
3892 };
3893 return match Box::pin(aggregator.retire_identity(identity)).await {
3894 Ok(true) => {
3895 if let Some(store) = &console_events {
3896 store
3897 .record_lifecycle(
3898 &canonical_identity,
3899 "identity_retired",
3900 json!({}),
3901 )
3902 .await;
3903 }
3904 response_value(
3905 response_id,
3906 Some(json!({ "identity": canonical_identity })),
3907 None,
3908 )
3909 }
3910 Ok(false) => response_value(
3911 response_id,
3912 None,
3913 Some(JsonRpcError {
3914 code: -32001,
3915 message: format!("unknown identity: {identity}"),
3916 data: None,
3917 }),
3918 ),
3919 Err(err) => internal_error(response_id, format!("retire failed: {err}")),
3920 };
3921 }
3922 let live_alias = match lookup_member_alias_with_session(
3923 &handle,
3924 visibility_policy,
3925 identity,
3926 )
3927 .await
3928 {
3929 Ok(alias) => alias,
3930 Err(err) => return response_value(response_id, None, Some(err)),
3931 };
3932 let Some(alias) = live_alias else {
3933 return invalid_params(response_id, format!("identity not found: {identity}"));
3934 };
3935 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
3936 return identity_hidden_by_policy_response(response_id, identity);
3937 }
3938 if let Err(err) =
3939 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
3940 {
3941 return response_value(response_id, None, Some(err));
3942 }
3943 let mid = MeerkatId::from(alias.runtime_member_id.as_str());
3944 match retire_console_member(&handle, &mid).await {
3945 Ok(()) => {
3946 if let Some(store) = &console_events {
3947 store
3948 .record_lifecycle(&alias.identity, "identity_retired", json!({}))
3949 .await;
3950 }
3951 response_value(
3952 response_id,
3953 Some(json!({ "identity": alias.identity })),
3954 None,
3955 )
3956 }
3957 Err(err) => internal_error(response_id, format!("retire failed: {err}")),
3958 }
3959 }
3960 "mobkit/respawn" => {
3961 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
3962 return invalid_params(response_id, "identity required");
3963 };
3964 let handle = runtime.handle();
3965 if let Some(identity_runtime) = &identity_runtime {
3966 let (parsed_identity, _requested_exact_identity, live_alias) =
3967 match resolve_console_identity_control_target(
3968 &handle,
3969 Some(identity_runtime),
3970 visibility_policy,
3971 identity,
3972 )
3973 .await
3974 {
3975 Ok(Some(target)) => target,
3976 Ok(None) => {
3977 return invalid_params(
3978 response_id,
3979 format!("identity not found: {identity}"),
3980 );
3981 }
3982 Err(err) => return response_value(response_id, None, Some(err)),
3983 };
3984 let registered_status = match identity_runtime.status(&parsed_identity).await {
3985 Ok(status) => Some(status),
3986 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => None,
3987 Err(err) => {
3988 return console_identity_error_response(response_id, "respawn", err);
3989 }
3990 };
3991 if let Some(status) = registered_status.as_ref() {
3992 if !identity_status_visible_to_console(visibility_policy, status) {
3993 return identity_hidden_by_policy_response(response_id, identity);
3994 }
3995 if let Some(error) = stale_live_alias_json_rpc_error(
3996 "respawn",
3997 identity_runtime,
3998 &parsed_identity,
3999 live_alias.as_ref(),
4000 )
4001 .await
4002 {
4003 return response_value(response_id, None, Some(error));
4004 }
4005 }
4006 match identity_runtime.respawn(&parsed_identity).await {
4007 Ok(mut record) => {
4008 let live_respawn_warning = match respawn_console_member(
4009 &handle,
4010 &MeerkatId::from(record.agent_runtime_id.as_str()),
4011 )
4012 .await
4013 {
4014 Ok(()) => {
4015 let live_session_id = handle
4016 .resolve_bridge_session_id_observation(&MeerkatId::from(
4017 record.agent_runtime_id.as_str(),
4018 ))
4019 .await;
4020 if let Some(live_session_id) = live_session_id {
4021 match identity_runtime
4022 .rebind_session_after_live_respawn(
4023 &parsed_identity,
4024 live_session_id.clone(),
4025 )
4026 .await
4027 {
4028 Ok(updated_record) => {
4029 record = updated_record;
4030 None
4031 }
4032 Err(err) => Some(json!({
4033 "kind": "identity_rebind_failed_after_member_respawn",
4034 "identity": record.identity.as_str(),
4035 "agent_runtime_id": record.agent_runtime_id.as_str(),
4036 "live_session_id": live_session_id.to_string(),
4037 "message": err.to_string(),
4038 })),
4039 }
4040 } else {
4041 None
4042 }
4043 }
4044 Err(err) => Some(json!({
4045 "kind": "member_respawn_failed_after_identity_refresh",
4046 "identity": record.identity.as_str(),
4047 "agent_runtime_id": record.agent_runtime_id.as_str(),
4048 "message": err,
4049 })),
4050 };
4051 let cleanup_warning = if registered_status.is_some()
4052 && let Err(err) = retire_stale_console_members_for_identity(
4053 &handle,
4054 visibility_policy,
4055 parsed_identity.as_str(),
4056 Some(record.agent_runtime_id.as_str()),
4057 )
4058 .await
4059 {
4060 Some(json!({
4061 "kind": "stale_member_cleanup_failed_after_identity_respawn",
4062 "identity": parsed_identity.as_str(),
4063 "agent_runtime_id": record.agent_runtime_id.as_str(),
4064 "message": err,
4065 }))
4066 } else {
4067 None
4068 };
4069 if let Some(store) = &console_events {
4070 store
4071 .record_lifecycle(
4072 parsed_identity.as_str(),
4073 "identity_respawned",
4074 json!({
4075 "generation": record.generation.get(),
4076 "checkpoint_version": record.checkpoint_version.get(),
4077 "live_respawn_warning": live_respawn_warning.clone(),
4078 "cleanup_warning": cleanup_warning.clone(),
4079 }),
4080 )
4081 .await;
4082 }
4083 return response_value(
4084 response_id,
4085 Some(json!({
4086 "identity": record.identity.as_str(),
4087 "agent_runtime_id": record.agent_runtime_id.as_str(),
4088 "session_id": record.session_id.to_string(),
4089 "generation": record.generation.get(),
4090 "checkpoint_version": record.checkpoint_version.get(),
4091 "live_respawn_warning": live_respawn_warning,
4092 "cleanup_warning": cleanup_warning,
4093 })),
4094 None,
4095 );
4096 }
4097 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
4098 if live_alias.is_none() {
4099 return invalid_params(
4100 response_id,
4101 format!("identity not found: {identity}"),
4102 );
4103 }
4104 }
4105 Err(err) => {
4106 return console_identity_error_response(response_id, "respawn", err);
4107 }
4108 }
4109 }
4110 let live_alias = match lookup_member_alias_with_session(
4111 &handle,
4112 visibility_policy,
4113 identity,
4114 )
4115 .await
4116 {
4117 Ok(alias) => alias,
4118 Err(err) => return response_value(response_id, None, Some(err)),
4119 };
4120 let Some(alias) = live_alias else {
4121 return invalid_params(response_id, format!("identity not found: {identity}"));
4122 };
4123 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
4124 return identity_hidden_by_policy_response(response_id, identity);
4125 }
4126 if let Err(err) =
4127 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
4128 {
4129 return response_value(response_id, None, Some(err));
4130 }
4131 let mid = MeerkatId::from(alias.runtime_member_id.as_str());
4132 match respawn_console_member(&handle, &mid).await {
4133 Ok(()) => {
4134 if let Some(store) = &console_events {
4135 store
4136 .record_lifecycle(&alias.identity, "identity_respawned", json!({}))
4137 .await;
4138 }
4139 let body = match lookup_member_with_session(&handle, &mid).await {
4140 Some((entry, session_id)) => console_identity_status_json_for_identity(
4141 &alias.identity,
4142 &entry,
4143 session_id,
4144 None,
4145 ),
4146 None => json!({ "identity": alias.identity }),
4147 };
4148 response_value(response_id, Some(body), None)
4149 }
4150 Err(err) => internal_error(response_id, format!("respawn failed: {err}")),
4151 }
4152 }
4153 "mobkit/reset" => {
4154 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
4155 return invalid_params(response_id, "identity required");
4156 };
4157 let handle = runtime.handle();
4158 let Some(identity_runtime) = &identity_runtime else {
4159 return invalid_params(response_id, "identity-first runtime required for reset");
4160 };
4161 let (parsed_identity, _requested_exact_identity, live_alias) =
4162 match resolve_console_identity_control_target(
4163 &handle,
4164 Some(identity_runtime),
4165 visibility_policy,
4166 identity,
4167 )
4168 .await
4169 {
4170 Ok(Some(target)) => target,
4171 Ok(None) => {
4172 return invalid_params(
4173 response_id,
4174 format!("identity not found: {identity}"),
4175 );
4176 }
4177 Err(err) => return response_value(response_id, None, Some(err)),
4178 };
4179 match identity_runtime.status(&parsed_identity).await {
4180 Ok(status) => {
4181 if !identity_status_visible_to_console(visibility_policy, &status) {
4182 return identity_hidden_by_policy_response(response_id, identity);
4183 }
4184 if let Some(error) = stale_live_alias_json_rpc_error(
4185 "reset",
4186 identity_runtime,
4187 &parsed_identity,
4188 live_alias.as_ref(),
4189 )
4190 .await
4191 {
4192 return response_value(response_id, None, Some(error));
4193 }
4194 if !identity_runtime.has_session_bridge() {
4195 return response_value(
4196 response_id,
4197 None,
4198 Some(reset_requires_session_bridge_json_rpc_error()),
4199 );
4200 }
4201 }
4202 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
4203 if let Some(alias) = live_alias.as_ref() {
4204 if !runtime_alias_visible_to_console(&handle, visibility_policy, alias) {
4205 return identity_hidden_by_policy_response(response_id, identity);
4206 }
4207 let mid = MeerkatId::from(alias.runtime_member_id.as_str());
4208 let response = match respawn_console_member(&handle, &mid).await {
4209 Ok(()) => {
4210 if let Some(store) = &console_events {
4211 store
4212 .record_lifecycle(
4213 &alias.identity,
4214 "identity_reset",
4215 json!({}),
4216 )
4217 .await;
4218 }
4219 let body = match lookup_member_with_session(&handle, &mid).await {
4220 Some((entry, session_id)) => {
4221 console_identity_status_json_for_identity(
4222 &alias.identity,
4223 &entry,
4224 session_id,
4225 None,
4226 )
4227 }
4228 None => json!({ "identity": alias.identity }),
4229 };
4230 response_value(response_id, Some(body), None)
4231 }
4232 Err(err) => internal_error(response_id, format!("reset failed: {err}")),
4233 };
4234 return response;
4235 }
4236 return invalid_params(response_id, format!("identity not found: {identity}"));
4237 }
4238 Err(err) => return console_identity_error_response(response_id, "reset", err),
4239 }
4240 match identity_runtime.reset(&parsed_identity).await {
4241 Ok(record) => {
4242 let cleanup_warning = if let Err(err) =
4243 retire_stale_console_members_for_identity(
4244 &handle,
4245 visibility_policy,
4246 parsed_identity.as_str(),
4247 Some(record.agent_runtime_id.as_str()),
4248 )
4249 .await
4250 {
4251 Some(json!({
4252 "kind": "stale_member_cleanup_failed_after_identity_reset",
4253 "identity": parsed_identity.as_str(),
4254 "agent_runtime_id": record.agent_runtime_id.as_str(),
4255 "message": err,
4256 }))
4257 } else {
4258 None
4259 };
4260 if let Some(store) = &console_events {
4261 store
4262 .record_lifecycle(
4263 parsed_identity.as_str(),
4264 "identity_reset",
4265 json!({
4266 "generation": record.generation.get(),
4267 "checkpoint_version": record.checkpoint_version.get(),
4268 "cleanup_warning": cleanup_warning.clone(),
4269 }),
4270 )
4271 .await;
4272 }
4273 response_value(
4274 response_id,
4275 Some(json!({
4276 "identity": record.identity.as_str(),
4277 "agent_runtime_id": record.agent_runtime_id.as_str(),
4278 "session_id": record.session_id.to_string(),
4279 "generation": record.generation.get(),
4280 "checkpoint_version": record.checkpoint_version.get(),
4281 "cleanup_warning": cleanup_warning,
4282 })),
4283 None,
4284 )
4285 }
4286 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
4287 invalid_params(response_id, format!("identity not found: {identity}"))
4288 }
4289 Err(err) => console_identity_error_response(response_id, "reset", err),
4290 }
4291 }
4292 "mobkit/delete_identity" => {
4293 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
4294 return invalid_params(response_id, "identity required");
4295 };
4296 let handle = runtime.handle();
4297 let Some(identity_runtime) = &identity_runtime else {
4298 return invalid_params(
4299 response_id,
4300 "identity-first runtime required for delete_identity",
4301 );
4302 };
4303 let (parsed_identity, _requested_exact_identity, live_alias) =
4304 match resolve_console_identity_control_target(
4305 &handle,
4306 Some(identity_runtime),
4307 visibility_policy,
4308 identity,
4309 )
4310 .await
4311 {
4312 Ok(Some(target)) => target,
4313 Ok(None) => {
4314 return invalid_params(
4315 response_id,
4316 format!("identity not found: {identity}"),
4317 );
4318 }
4319 Err(err) => return response_value(response_id, None, Some(err)),
4320 };
4321 let registered_status = match identity_runtime.status(&parsed_identity).await {
4322 Ok(status) => status,
4323 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
4324 if let Some(alias) = live_alias.as_ref() {
4325 if !runtime_alias_visible_to_console(&handle, visibility_policy, alias) {
4326 return identity_hidden_by_policy_response(response_id, identity);
4327 }
4328 return response_value(
4329 response_id,
4330 None,
4331 Some(JsonRpcError {
4332 code: -32602,
4333 message: format!(
4334 "delete_identity requires durable identity: {} is live-only",
4335 parsed_identity.as_str()
4336 ),
4337 data: Some(json!({
4338 "kind": "live_only_identity_delete_unsupported",
4339 "identity": parsed_identity.as_str(),
4340 })),
4341 }),
4342 );
4343 }
4344 return invalid_params(response_id, format!("identity not found: {identity}"));
4345 }
4346 Err(err) => {
4347 return console_identity_error_response(response_id, "delete_identity", err);
4348 }
4349 };
4350 if !identity_status_visible_to_console(visibility_policy, ®istered_status) {
4351 return identity_hidden_by_policy_response(response_id, identity);
4352 }
4353 if let Some(error) = stale_live_alias_json_rpc_error(
4354 "delete_identity",
4355 identity_runtime,
4356 &parsed_identity,
4357 live_alias.as_ref(),
4358 )
4359 .await
4360 {
4361 return response_value(response_id, None, Some(error));
4362 }
4363 match identity_runtime.delete_identity(&parsed_identity).await {
4364 Ok(()) => {
4365 let keep_runtime_member_id = registered_status
4366 .agent_runtime_id
4367 .as_ref()
4368 .filter(|_| identity_runtime.has_session_bridge())
4369 .map(crate::identity_first::AgentRuntimeId::as_str);
4370 let cleanup_warning = if let Err(err) =
4371 retire_stale_console_members_for_identity(
4372 &handle,
4373 visibility_policy,
4374 parsed_identity.as_str(),
4375 keep_runtime_member_id,
4376 )
4377 .await
4378 {
4379 Some(json!({
4380 "kind": "stale_member_cleanup_failed_after_identity_delete",
4381 "identity": parsed_identity.as_str(),
4382 "message": err,
4383 }))
4384 } else {
4385 None
4386 };
4387 if let Some(store) = &console_events {
4388 store
4389 .record_lifecycle(
4390 parsed_identity.as_str(),
4391 "identity_deleted",
4392 json!({
4393 "cleanup_warning": cleanup_warning.clone(),
4394 }),
4395 )
4396 .await;
4397 }
4398 response_value(
4399 response_id,
4400 Some(json!({
4401 "identity": parsed_identity.as_str(),
4402 "cleanup_warning": cleanup_warning,
4403 })),
4404 None,
4405 )
4406 }
4407 Err(err) => console_identity_error_response(response_id, "delete_identity", err),
4408 }
4409 }
4410 "mobkit/reset_all" => {
4411 match Box::pin(reset_all_live_console_agents(
4412 runtime,
4413 console_events.as_ref(),
4414 console_aggregator.as_ref(),
4415 identity_runtime.as_ref(),
4416 visibility_policy,
4417 ))
4418 .await
4419 {
4420 Ok(body) => {
4421 if body
4422 .get("failed")
4423 .and_then(Value::as_array)
4424 .is_some_and(|failed| !failed.is_empty())
4425 {
4426 response_value(
4427 response_id,
4428 None,
4429 Some(JsonRpcError {
4430 code: -32000,
4431 message: "reset_all failed for one or more identities".to_string(),
4432 data: Some(body),
4433 }),
4434 )
4435 } else {
4436 response_value(response_id, Some(body), None)
4437 }
4438 }
4439 Err(err) => internal_error(response_id, format!("reset_all failed: {err}")),
4440 }
4441 }
4442 "mobkit/routing/routes/list" => {
4443 let Some(module_runtime) = &module_runtime else {
4444 return response_value(
4445 response_id,
4446 None,
4447 Some(JsonRpcError {
4448 code: -32601,
4449 message: "Method not found".to_string(),
4450 data: None,
4451 }),
4452 );
4453 };
4454 let routes = module_runtime.lock().await.list_runtime_routes();
4455 response_value(response_id, Some(json!({ "routes": routes })), None)
4456 }
4457 "mobkit/delivery/history" => {
4458 let Some(module_runtime) = &module_runtime else {
4459 return response_value(
4460 response_id,
4461 None,
4462 Some(JsonRpcError {
4463 code: -32601,
4464 message: "Method not found".to_string(),
4465 data: None,
4466 }),
4467 );
4468 };
4469 let limit = request
4470 .params
4471 .get("limit")
4472 .and_then(Value::as_u64)
4473 .unwrap_or(50) as usize;
4474 let history = module_runtime
4475 .lock()
4476 .await
4477 .delivery_history(DeliveryHistoryRequest {
4478 recipient: None,
4479 sink: None,
4480 limit,
4481 });
4482 response_value(
4483 response_id,
4484 Some(serde_json::to_value(history).unwrap_or(Value::Null)),
4485 None,
4486 )
4487 }
4488 "mobkit/gating/pending" => {
4489 let Some(module_runtime) = &module_runtime else {
4490 return response_value(
4491 response_id,
4492 None,
4493 Some(JsonRpcError {
4494 code: -32601,
4495 message: "Method not found".to_string(),
4496 data: None,
4497 }),
4498 );
4499 };
4500 let pending = module_runtime.lock().await.list_gating_pending();
4501 response_value(response_id, Some(json!({ "pending": pending })), None)
4502 }
4503 "mobkit/gating/audit" => {
4504 let Some(module_runtime) = &module_runtime else {
4505 return response_value(
4506 response_id,
4507 None,
4508 Some(JsonRpcError {
4509 code: -32601,
4510 message: "Method not found".to_string(),
4511 data: None,
4512 }),
4513 );
4514 };
4515 let limit = request
4516 .params
4517 .get("limit")
4518 .and_then(Value::as_u64)
4519 .unwrap_or(50) as usize;
4520 let entries = module_runtime.lock().await.gating_audit_entries(limit);
4521 response_value(response_id, Some(json!({ "entries": entries })), None)
4522 }
4523 "mobkit/gating/decide" => {
4524 let Some(module_runtime) = &module_runtime else {
4525 return response_value(
4526 response_id,
4527 None,
4528 Some(JsonRpcError {
4529 code: -32601,
4530 message: "Method not found".to_string(),
4531 data: None,
4532 }),
4533 );
4534 };
4535 let Some(pending_id) = request.params.get("pending_id").and_then(Value::as_str) else {
4536 return invalid_params(response_id, "pending_id required");
4537 };
4538 let Some(approver_id) = request.params.get("approver_id").and_then(Value::as_str)
4539 else {
4540 return invalid_params(response_id, "approver_id required");
4541 };
4542 let Some(raw_decision) = request.params.get("decision").and_then(Value::as_str) else {
4543 return invalid_params(response_id, "decision required");
4544 };
4545 let decision = match raw_decision {
4546 "approve" => GatingDecision::Approve,
4547 "reject" | "deny" => GatingDecision::Reject,
4548 "escalate" => GatingDecision::Escalate,
4549 _ => {
4550 return invalid_params(
4551 response_id,
4552 format!("unsupported decision: {raw_decision}"),
4553 );
4554 }
4555 };
4556 let reason = request
4557 .params
4558 .get("reason")
4559 .and_then(Value::as_str)
4560 .map(ToString::to_string);
4561 match module_runtime
4562 .lock()
4563 .await
4564 .decide_gating_action(GatingDecideRequest {
4565 pending_id: pending_id.to_string(),
4566 approver_id: approver_id.to_string(),
4567 decision,
4568 reason,
4569 }) {
4570 Ok(result) => response_value(
4571 response_id,
4572 Some(serde_json::to_value(result).unwrap_or(Value::Null)),
4573 None,
4574 ),
4575 Err(err) => invalid_params(response_id, format!("gating decision failed: {err}")),
4576 }
4577 }
4578 "mobkit/ensure_member" => {
4579 let Some(role) = request.params.get("role").and_then(Value::as_str) else {
4580 return invalid_params(response_id, "role required");
4581 };
4582 let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
4583 else {
4584 return invalid_params(response_id, "agent_identity required");
4585 };
4586 let labels = match request.params.get("labels") {
4587 None | Some(Value::Null) => std::collections::BTreeMap::new(),
4588 Some(value) => match serde_json::from_value(value.clone()) {
4589 Ok(map) => map,
4590 Err(err) => {
4591 return invalid_params(response_id, format!("invalid labels: {err}"));
4592 }
4593 },
4594 };
4595 let context = request.params.get("context").cloned();
4596 let resume_session_id = match request.params.get("resume_session_id") {
4597 None => None,
4598 Some(Value::Null) => None,
4599 Some(v) => match v.as_str() {
4600 Some(s) => match meerkat_core::types::SessionId::parse(s) {
4601 Ok(sid) => Some(sid),
4602 Err(_) => {
4603 return invalid_params(
4604 response_id,
4605 format!("invalid resume_session_id: {s:?}"),
4606 );
4607 }
4608 },
4609 None => {
4610 return invalid_params(
4611 response_id,
4612 "resume_session_id must be a string".to_string(),
4613 );
4614 }
4615 },
4616 };
4617 let additional_instructions = match request.params.get("additional_instructions") {
4618 None | Some(Value::Null) => None,
4619 Some(Value::Array(arr)) => {
4620 let mut strs = Vec::with_capacity(arr.len());
4621 for (i, entry) in arr.iter().enumerate() {
4622 match entry.as_str() {
4623 Some(s) => strs.push(s.to_string()),
4624 None => {
4625 return invalid_params(
4626 response_id,
4627 format!("additional_instructions[{i}] must be a string"),
4628 );
4629 }
4630 }
4631 }
4632 if strs.is_empty() { None } else { Some(strs) }
4633 }
4634 Some(_) => {
4635 return invalid_params(
4636 response_id,
4637 "additional_instructions must be an array of strings",
4638 );
4639 }
4640 };
4641 let mut spec =
4642 SpawnMemberSpec::new(ProfileName::from(role), MeerkatId::from(agent_identity));
4643 if !labels.is_empty() {
4644 spec = spec.with_labels(labels);
4645 }
4646 if let Some(ctx) = context {
4647 spec = spec.with_context(ctx);
4648 }
4649 if let Some(sid) = resume_session_id {
4650 spec = spec.with_resume_bridge_session_id(sid);
4651 }
4652 if let Some(instructions) = additional_instructions {
4653 spec = spec.with_additional_instructions(instructions);
4654 }
4655 let handle = runtime.handle();
4656 let mid = spec.identity.clone();
4657 match handle.ensure_member(spec).await {
4658 Ok(_outcome) => {
4659 let body = match lookup_member_with_session(&handle, &mid).await {
4660 Some((entry, _sid)) => member_entry_to_json(&entry),
4661 None => Value::Null,
4662 };
4663 response_value(response_id, Some(body), None)
4664 }
4665 Err(err) => internal_error(response_id, format!("ensure_member failed: {err}")),
4666 }
4667 }
4668 "mobkit/retire_member" => {
4669 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
4670 return invalid_params(response_id, "member_id required");
4671 };
4672 if let Some(aggregator) = &console_aggregator {
4673 return match Box::pin(aggregator.retire_identity(member_id)).await {
4674 Ok(true) => response_value(
4675 response_id,
4676 Some(serde_json::json!({ "accepted": true })),
4677 None,
4678 ),
4679 Ok(false) => response_value(
4680 response_id,
4681 None,
4682 Some(JsonRpcError {
4683 code: -32001,
4684 message: format!("unknown identity: {member_id}"),
4685 data: None,
4686 }),
4687 ),
4688 Err(err) => internal_error(response_id, format!("retire_member failed: {err}")),
4689 };
4690 }
4691 match runtime.handle().retire(MeerkatId::from(member_id)).await {
4692 Ok(()) => response_value(
4693 response_id,
4694 Some(serde_json::json!({ "accepted": true })),
4695 None,
4696 ),
4697 Err(err) => internal_error(response_id, format!("retire_member failed: {err}")),
4698 }
4699 }
4700 "mobkit/respawn_member" => {
4701 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
4702 return invalid_params(response_id, "member_id required");
4703 };
4704 match runtime
4705 .handle()
4706 .respawn(MeerkatId::from(member_id), None)
4707 .await
4708 {
4709 Ok(_receipt) => response_value(
4710 response_id,
4711 Some(serde_json::json!({ "accepted": true })),
4712 None,
4713 ),
4714 Err(err) => internal_error(response_id, format!("respawn_member failed: {err}")),
4715 }
4716 }
4717 "mobkit/reconcile_edges" => response_value(
4718 response_id,
4719 Some(serde_json::json!({
4720 "status": "noop",
4721 "reason": "console runtime routes directly to MobRuntime",
4722 })),
4723 None,
4724 ),
4725 "mobkit/mob_events/query" | "mobkit/mob_events/subscribe" => {
4726 let query: EventQuery = if request.params.is_null() {
4727 EventQuery::default()
4728 } else {
4729 match serde_json::from_value(request.params.clone()) {
4730 Ok(q) => q,
4731 Err(err) => {
4732 return invalid_params(response_id, format!("invalid query params: {err}"));
4733 }
4734 }
4735 };
4736 let Some(store) = mob_events.as_ref() else {
4737 return response_value(
4738 response_id,
4739 Some(serde_json::json!({
4740 "events": [],
4741 "next_after_seq": Value::Null,
4742 })),
4743 None,
4744 );
4745 };
4746 let events_view = runtime.handle().events();
4747 let latest_at_handshake = events_view.latest_cursor().await.unwrap_or(0);
4751 let result = crate::unified_runtime::mob_events::query_ledger_with_filter(
4752 &events_view,
4753 store,
4754 &query,
4755 )
4756 .await;
4757 match result {
4758 Ok(events) => {
4759 let last_cursor = events.last().map(|event| event.cursor);
4760 let body = if request.method == "mobkit/mob_events/subscribe" {
4761 let subscribe_url = crate::unified_runtime::mob_events::build_subscribe_url(
4762 &query,
4763 last_cursor,
4764 latest_at_handshake,
4765 );
4766 serde_json::json!({
4767 "stream": "mob_events",
4768 "events": events,
4769 "next_after_seq": last_cursor,
4770 "subscribe_url": subscribe_url,
4771 "keep_alive": {
4772 "interval_ms": 15_000_u64,
4773 "event": "keep_alive",
4774 },
4775 })
4776 } else {
4777 serde_json::json!({
4778 "events": events,
4779 "next_after_seq": last_cursor,
4780 })
4781 };
4782 response_value(response_id, Some(body), None)
4783 }
4784 Err(crate::unified_runtime::mob_events::MobEventsQueryError::Stale {
4785 after_cursor,
4786 latest_cursor,
4787 }) => stale_event_cursor_response(response_id, after_cursor, latest_cursor),
4788 Err(err) => internal_error(response_id, format!("mob_events query failed: {err}")),
4789 }
4790 }
4791 "mobkit/member_status" => {
4793 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
4794 return invalid_params(response_id, "member_id required");
4795 };
4796 match runtime
4797 .handle()
4798 .member_status(&MeerkatId::from(member_id))
4799 .await
4800 {
4801 Ok(snapshot) => response_value(
4802 response_id,
4803 Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
4804 None,
4805 ),
4806 Err(err) => internal_error(response_id, format!("member_status failed: {err}")),
4807 }
4808 }
4809 "mobkit/force_cancel_member" => {
4810 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
4811 return invalid_params(response_id, "member_id required");
4812 };
4813 match runtime
4814 .handle()
4815 .force_cancel_member(MeerkatId::from(member_id))
4816 .await
4817 {
4818 Ok(()) => response_value(
4819 response_id,
4820 Some(serde_json::json!({ "accepted": true })),
4821 None,
4822 ),
4823 Err(err) => {
4824 internal_error(response_id, format!("force_cancel_member failed: {err}"))
4825 }
4826 }
4827 }
4828 "mobkit/wait_ready" => {
4829 let timeout = request
4830 .params
4831 .get("timeout_ms")
4832 .and_then(Value::as_u64)
4833 .map(std::time::Duration::from_millis);
4834 match runtime.handle().wait_for_ready(timeout).await {
4835 Ok(ready) => {
4836 let entries: Vec<Value> = ready
4837 .into_iter()
4838 .map(|(identity, snapshot)| {
4839 serde_json::json!({
4840 "agent_identity": identity.to_string(),
4841 "snapshot": serde_json::to_value(&snapshot)
4842 .unwrap_or(Value::Null),
4843 })
4844 })
4845 .collect();
4846 response_value(
4847 response_id,
4848 Some(serde_json::json!({
4849 "ready": entries,
4850 "timeout": false,
4851 })),
4852 None,
4853 )
4854 }
4855 Err(err) => {
4856 let message = err.to_string();
4857 if message.to_lowercase().contains("timeout") {
4858 response_value(
4859 response_id,
4860 Some(serde_json::json!({
4861 "ready": Vec::<Value>::new(),
4862 "timeout": true,
4863 })),
4864 None,
4865 )
4866 } else {
4867 internal_error(response_id, format!("wait_for_ready failed: {message}"))
4868 }
4869 }
4870 }
4871 }
4872 "mobkit/collect_completed" => {
4873 let completed = runtime.handle().collect_completed().await;
4874 let entries: Vec<Value> = completed
4875 .into_iter()
4876 .map(|(mid, snapshot)| {
4877 serde_json::json!({
4878 "member_id": mid.to_string(),
4879 "snapshot": serde_json::to_value(&snapshot).unwrap_or(Value::Null),
4880 })
4881 })
4882 .collect();
4883 response_value(
4884 response_id,
4885 Some(serde_json::json!({ "completed": entries })),
4886 None,
4887 )
4888 }
4889 "mobkit/cancel_flow" => {
4890 let Some(run_id) = request.params.get("run_id").and_then(Value::as_str) else {
4891 return invalid_params(response_id, "run_id required");
4892 };
4893 let run_id: meerkat_mob::RunId = match run_id.parse() {
4894 Ok(id) => id,
4895 Err(_) => return invalid_params(response_id, "invalid run_id format"),
4896 };
4897 match runtime.handle().cancel_flow(run_id).await {
4898 Ok(()) => response_value(
4899 response_id,
4900 Some(serde_json::json!({ "accepted": true })),
4901 None,
4902 ),
4903 Err(err) => internal_error(response_id, format!("cancel_flow failed: {err}")),
4904 }
4905 }
4906 "mobkit/flow_status" => {
4907 let Some(run_id) = request.params.get("run_id").and_then(Value::as_str) else {
4908 return invalid_params(response_id, "run_id required");
4909 };
4910 let run_id: meerkat_mob::RunId = match run_id.parse() {
4911 Ok(id) => id,
4912 Err(_) => return invalid_params(response_id, "invalid run_id format"),
4913 };
4914 match runtime.handle().flow_status(run_id).await {
4915 Ok(Some(mob_run)) => response_value(
4916 response_id,
4917 Some(serde_json::to_value(&mob_run).unwrap_or(Value::Null)),
4918 None,
4919 ),
4920 Ok(None) => response_value(response_id, Some(Value::Null), None),
4921 Err(err) => internal_error(response_id, format!("flow_status failed: {err}")),
4922 }
4923 }
4924 "mobkit/list_flows" => {
4925 let flows: Vec<String> = runtime
4926 .handle()
4927 .list_flows()
4928 .into_iter()
4929 .map(|id| id.to_string())
4930 .collect();
4931 response_value(
4932 response_id,
4933 Some(serde_json::json!({ "flows": flows })),
4934 None,
4935 )
4936 }
4937 "mobkit/list_runs" => {
4938 let flow_id = request
4939 .params
4940 .get("flow_id")
4941 .and_then(Value::as_str)
4942 .filter(|value| !value.is_empty())
4943 .map(meerkat_mob::FlowId::from);
4944 match runtime.handle().list_runs(flow_id.as_ref()).await {
4945 Ok(runs) => response_value(
4946 response_id,
4947 Some(serde_json::json!({
4948 "runs": serde_json::to_value(&runs).unwrap_or(Value::Null),
4949 })),
4950 None,
4951 ),
4952 Err(err) => internal_error(response_id, format!("list_runs failed: {err}")),
4953 }
4954 }
4955 "mobkit/run_flow" => {
4956 let Some(flow_id_str) = request.params.get("flow_id").and_then(Value::as_str) else {
4957 return invalid_params(response_id, "flow_id required");
4958 };
4959 if flow_id_str.is_empty() {
4960 return invalid_params(response_id, "flow_id required");
4961 }
4962 let flow_id = meerkat_mob::FlowId::from(flow_id_str);
4963 let flow_params = request.params.get("params").cloned().unwrap_or(Value::Null);
4964 if let Some(identity_runtime) = &identity_runtime
4965 && let Err(err) = identity_runtime.materialize_all().await
4966 {
4967 return internal_error(
4968 response_id,
4969 format!("identity-first flow materialization failed: {err}"),
4970 );
4971 }
4972 match runtime.handle().run_flow(flow_id, flow_params).await {
4973 Ok(run_id) => response_value(
4974 response_id,
4975 Some(serde_json::json!({ "run_id": run_id.to_string() })),
4976 None,
4977 ),
4978 Err(err) => invalid_params(response_id, format!("run_flow failed: {err}")),
4979 }
4980 }
4981 "mobkit/spawn_helper" => {
4982 let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
4983 else {
4984 return invalid_params(response_id, "agent_identity required");
4985 };
4986 let Some(task) = request.params.get("task").and_then(Value::as_str) else {
4987 return invalid_params(response_id, "task required");
4988 };
4989 let options = match parse_console_helper_options(request.params.get("options")) {
4990 Ok(opts) => opts,
4991 Err(msg) => return invalid_params(response_id, msg),
4992 };
4993 let handle = runtime.handle();
4994 match handle
4995 .spawn_helper(MeerkatId::from(agent_identity), task, options)
4996 .await
4997 {
4998 Ok(result) => {
4999 response_value(
5004 response_id,
5005 Some(serde_json::json!({
5006 "output": result.output,
5007 "tokens_used": result.tokens_used,
5008 })),
5009 None,
5010 )
5011 }
5012 Err(err) => internal_error(response_id, format!("spawn_helper failed: {err}")),
5013 }
5014 }
5015 "mobkit/fork_helper" => {
5016 let Some(source) = request
5017 .params
5018 .get("source_member_id")
5019 .and_then(Value::as_str)
5020 else {
5021 return invalid_params(response_id, "source_member_id required");
5022 };
5023 let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
5024 else {
5025 return invalid_params(response_id, "agent_identity required");
5026 };
5027 let Some(task) = request.params.get("task").and_then(Value::as_str) else {
5028 return invalid_params(response_id, "task required");
5029 };
5030 let fork_context = match request.params.get("fork_context") {
5031 Some(v) if !v.is_null() => {
5032 match serde_json::from_value::<meerkat_mob::launch::ForkContext>(v.clone()) {
5033 Ok(ctx) => ctx,
5034 Err(err) => {
5035 return invalid_params(
5036 response_id,
5037 format!("invalid fork_context: {err}"),
5038 );
5039 }
5040 }
5041 }
5042 _ => meerkat_mob::launch::ForkContext::default(),
5043 };
5044 let options = match parse_console_helper_options(request.params.get("options")) {
5045 Ok(opts) => opts,
5046 Err(msg) => return invalid_params(response_id, msg),
5047 };
5048 let handle = runtime.handle();
5049 match handle
5050 .fork_helper(
5051 &MeerkatId::from(source),
5052 MeerkatId::from(agent_identity),
5053 task,
5054 fork_context,
5055 options,
5056 )
5057 .await
5058 {
5059 Ok(result) => {
5060 response_value(
5064 response_id,
5065 Some(serde_json::json!({
5066 "output": result.output,
5067 "tokens_used": result.tokens_used,
5068 })),
5069 None,
5070 )
5071 }
5072 Err(err) => internal_error(response_id, format!("fork_helper failed: {err}")),
5073 }
5074 }
5075 "mobkit/attach_existing_session" => {
5076 let Some(role) = request.params.get("role").and_then(Value::as_str) else {
5077 return invalid_params(response_id, "role required");
5078 };
5079 let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
5080 else {
5081 return invalid_params(response_id, "agent_identity required");
5082 };
5083 let Some(session_id_str) = request.params.get("session_id").and_then(Value::as_str)
5084 else {
5085 return invalid_params(response_id, "session_id required");
5086 };
5087 let bridge_session_id = match meerkat_core::types::SessionId::parse(session_id_str) {
5088 Ok(s) => s,
5089 Err(_) => return invalid_params(response_id, "invalid session_id format"),
5090 };
5091 let mid = MeerkatId::from(agent_identity);
5092 let spec = SpawnMemberSpec::new(ProfileName::from(role), mid.clone())
5093 .with_launch_mode(MemberLaunchMode::Resume { bridge_session_id });
5094 let handle = runtime.handle();
5095 match handle.spawn_spec(spec).await {
5096 Ok(_) => match handle.member_status(&mid).await {
5097 Ok(snapshot) => response_value(
5098 response_id,
5099 Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
5100 None,
5101 ),
5102 Err(err) => internal_error(
5103 response_id,
5104 format!("attach_existing_session status lookup failed: {err}"),
5105 ),
5106 },
5107 Err(err) => internal_error(
5108 response_id,
5109 format!("attach_existing_session failed: {err}"),
5110 ),
5111 }
5112 }
5113 "mobkit/cross_mob/wire_local" => {
5114 handle_console_wire_local(runtime, &request.params, response_id, true).await
5115 }
5116 "mobkit/cross_mob/unwire_local" => {
5117 handle_console_wire_local(runtime, &request.params, response_id, false).await
5118 }
5119 "mobkit/peer_pubkey" => match gateway_peer_keys {
5120 Some(keys) => response_value(
5121 response_id,
5122 Some(serde_json::json!({ "pubkey_b64": keys.pubkey_b64() })),
5123 None,
5124 ),
5125 None => response_value(
5126 response_id,
5127 None,
5128 Some(JsonRpcError {
5129 code: -32004,
5130 message: "gateway has no signing keypair configured".to_string(),
5131 data: None,
5132 }),
5133 ),
5134 },
5135 "mobkit/cross_mob/peer_info" => {
5136 let member_id = request.params.get("member_id").and_then(Value::as_str);
5137 match member_id {
5138 Some(mid) if !mid.is_empty() => {
5139 let handle = runtime.handle();
5140 let mob_id = handle.mob_id().to_string();
5141 let meerkat_id = MeerkatId::from(mid);
5142 match handle.get_member(&meerkat_id).await {
5143 Some(entry) => match entry.peer_id() {
5144 Some(peer_id) => {
5145 let comms_name = format!("{}/{}/{}", mob_id, entry.role, mid);
5146 let address = format!("inproc://{comms_name}");
5147 response_value(
5148 response_id,
5149 Some(serde_json::json!({
5150 "member_id": mid,
5151 "mob_id": mob_id,
5152 "comms_name": comms_name,
5153 "peer_id": peer_id,
5154 "address": address,
5155 })),
5156 None,
5157 )
5158 }
5159 None => response_value(
5160 response_id,
5161 None,
5162 Some(JsonRpcError {
5163 code: -32000,
5164 message: format!("member {mid:?} has no comms runtime"),
5165 data: None,
5166 }),
5167 ),
5168 },
5169 None => response_value(
5170 response_id,
5171 None,
5172 Some(JsonRpcError {
5173 code: -32000,
5174 message: format!("member {mid:?} not found"),
5175 data: None,
5176 }),
5177 ),
5178 }
5179 }
5180 _ => invalid_params(response_id, "member_id required".to_string()),
5181 }
5182 }
5183 "mobkit/cross_mob/directory" => {
5184 let entries: Vec<Value> = contact_directory
5185 .map(|dir| {
5186 dir.list()
5187 .into_iter()
5188 .filter_map(|e| serde_json::to_value(e).ok())
5189 .collect()
5190 })
5191 .unwrap_or_default();
5192 response_value(
5193 response_id,
5194 Some(serde_json::json!({ "mobs": entries })),
5195 None,
5196 )
5197 }
5198 method
5199 if matches!(
5200 method,
5201 "mobkit/mob_labels/set"
5202 | "mobkit/mob_labels/get"
5203 | "mobkit/mob_labels/delete"
5204 | "mobkit/run_labels/set"
5205 | "mobkit/run_labels/get"
5206 | "mobkit/run_labels/delete",
5207 ) =>
5208 {
5209 dispatch_label_method(
5210 method,
5211 metadata_table.as_deref(),
5212 runtime.handle().mob_id().as_str(),
5213 response_id,
5214 &request.params,
5215 )
5216 .await
5217 }
5218 _ => response_value(
5219 response_id,
5220 None,
5221 Some(JsonRpcError {
5222 code: -32601,
5223 message: "Method not found".to_string(),
5224 data: None,
5225 }),
5226 ),
5227 }
5228}
5229
5230async fn dispatch_label_method(
5237 method: &str,
5238 metadata_table: Option<&RuntimeMetadataTable>,
5239 mob_id: &str,
5240 response_id: Value,
5241 params: &Value,
5242) -> Value {
5243 let Some(table) = metadata_table else {
5244 return invalid_params(
5245 response_id,
5246 "metadata table not configured for this runtime",
5247 );
5248 };
5249
5250 let scope = match method {
5251 "mobkit/mob_labels/set" | "mobkit/mob_labels/get" | "mobkit/mob_labels/delete" => {
5252 MetadataScope::Mob(mob_id.to_string())
5253 }
5254 _ => match crate::runtime::parse_run_id_param(params) {
5255 Ok(run_id) => MetadataScope::Run(mob_id.to_string(), run_id.to_string()),
5256 Err(message) => return invalid_params(response_id, message),
5257 },
5258 };
5259
5260 let outcome = match method {
5261 "mobkit/mob_labels/set" | "mobkit/run_labels/set" => {
5262 crate::runtime::dispatch_labels_set(table, scope, params).await
5263 }
5264 "mobkit/mob_labels/get" | "mobkit/run_labels/get" => {
5265 crate::runtime::dispatch_labels_get(table, scope).await
5266 }
5267 "mobkit/mob_labels/delete" | "mobkit/run_labels/delete" => {
5268 crate::runtime::dispatch_labels_delete(table, scope).await
5269 }
5270 _ => unreachable!("dispatch_label_method called with non-label method: {method}"),
5271 };
5272
5273 match outcome {
5274 crate::runtime::LabelRpcResult::Accepted => response_value(
5275 response_id,
5276 Some(serde_json::json!({"accepted": true})),
5277 None,
5278 ),
5279 crate::runtime::LabelRpcResult::Labels(labels) => response_value(
5280 response_id,
5281 Some(serde_json::json!({"labels": labels_to_json_value(&labels)})),
5282 None,
5283 ),
5284 crate::runtime::LabelRpcResult::InvalidParams(message) => {
5285 invalid_params(response_id, message)
5286 }
5287 }
5288}
5289
5290async fn handle_console_wire_local(
5299 runtime: &MobRuntime,
5300 params: &Value,
5301 response_id: Value,
5302 wire: bool,
5303) -> Value {
5304 let local = params.get("local_member_id").and_then(Value::as_str);
5305 let comms_name = params.get("remote_comms_name").and_then(Value::as_str);
5306 let peer_id = params.get("remote_peer_id").and_then(Value::as_str);
5307 let addr = params.get("remote_address").and_then(Value::as_str);
5308
5309 let remote_pubkey = match params.get("remote_pubkey_b64") {
5310 None => None,
5311 Some(v) if v.is_null() => None,
5312 Some(v) => match v.as_str() {
5313 Some(s) if !s.is_empty() => match crate::auth::peer_keys::decode_pubkey_b64(s) {
5314 Ok(bytes) => Some(bytes),
5315 Err(err) => {
5316 return invalid_params(response_id, format!("remote_pubkey_b64: {err}"));
5317 }
5318 },
5319 _ => None,
5320 },
5321 };
5322
5323 let (local_id, cname, pid, address) = match (local, comms_name, peer_id, addr) {
5324 (Some(l), Some(c), Some(p), Some(a))
5325 if !l.is_empty() && !c.is_empty() && !p.is_empty() && !a.is_empty() =>
5326 {
5327 (l, c, p, a)
5328 }
5329 _ => {
5330 return invalid_params(
5331 response_id,
5332 "local_member_id, remote_comms_name, remote_peer_id, and remote_address required",
5333 );
5334 }
5335 };
5336
5337 let is_inproc = address.starts_with("inproc://");
5338 let spec_result = match (is_inproc, remote_pubkey) {
5339 (true, None) => TrustedPeerDescriptor::test_only_unsigned(cname, pid, address),
5340 (true, Some(bytes)) => {
5341 TrustedPeerDescriptor::unsigned_with_pubkey(cname, pid, bytes, address)
5342 }
5343 (false, None) => {
5344 return invalid_params(
5345 response_id,
5346 "remote_pubkey_b64 is required for non-inproc transports",
5347 );
5348 }
5349 (false, Some(bytes)) => {
5350 if bytes == [0u8; 32] {
5351 return invalid_params(
5352 response_id,
5353 "remote_pubkey_b64 must be non-zero for non-inproc transports",
5354 );
5355 }
5356 TrustedPeerDescriptor::unsigned_with_pubkey(cname, pid, bytes, address)
5357 }
5358 };
5359
5360 let spec = match spec_result {
5361 Ok(spec) => spec,
5362 Err(err) => {
5363 return invalid_params(response_id, format!("invalid peer spec: {err}"));
5364 }
5365 };
5366
5367 let result = if wire {
5368 runtime
5369 .handle()
5370 .wire(MeerkatId::from(local_id), PeerTarget::External(spec))
5371 .await
5372 } else {
5373 runtime
5374 .handle()
5375 .unwire(MeerkatId::from(local_id), PeerTarget::External(spec))
5376 .await
5377 };
5378
5379 let action = if wire { "wire_local" } else { "unwire_local" };
5380 match result {
5381 Ok(()) => response_value(
5382 response_id,
5383 Some(serde_json::json!({
5384 "accepted": true,
5385 "local_member_id": local_id,
5386 "remote_comms_name": cname,
5387 })),
5388 None,
5389 ),
5390 Err(err) => internal_error(response_id, format!("cross_mob/{action} failed: {err}")),
5391 }
5392}
5393
5394async fn build_live_snapshot(
5395 runtime: &MobRuntime,
5396 config_module_ids: &[String],
5397 console_events: Option<&ConsoleEventStore>,
5398 visibility_policy: &dyn ConsoleVisibilityPolicy,
5399 read_model: &ConsoleSnapshotReadModel,
5400) -> ConsoleLiveSnapshot {
5401 let read_model_state = read_model.snapshot(runtime).await;
5402 let running = read_model_state.running.unwrap_or(true);
5403 let mut members = read_model_state.primary_members.clone();
5410 if visibility_policy.include_implicit_delegate_members() {
5411 for group in &read_model_state.delegate_member_groups {
5412 members.extend(group.iter().cloned());
5413 }
5414 }
5415 dedupe_console_members_by_identity(&mut members);
5416 members.retain(|member| {
5417 visibility_policy.member_visible(member)
5418 && visibility_policy
5419 .identity_visible(&console_identity_record_from_console_member(member))
5420 });
5421
5422 let loaded_modules = if config_module_ids.is_empty() {
5426 let mut mods: Vec<String> = members
5427 .iter()
5428 .filter(|member| member.state != MEMBER_STATE_RETIRING)
5429 .map(|member| member.agent_identity.clone())
5430 .collect();
5431 mods.sort();
5432 mods
5433 } else {
5434 let mut mods = config_module_ids.to_vec();
5435 mods.sort();
5436 mods
5437 };
5438
5439 let agents = members
5440 .iter()
5441 .map(|member| async move {
5442 let console_identity = console_member_console_identity(member);
5443 let label = member
5444 .labels
5445 .get("display_name")
5446 .cloned()
5447 .unwrap_or_else(|| member.agent_identity.clone());
5448 let watched = member
5449 .labels
5450 .get("console_watched")
5451 .map(|value: &String| value == "true");
5452 let alert_level = member
5453 .labels
5454 .get("console_alert_level")
5455 .filter(|value: &&String| matches!(value.as_str(), "elevated" | "critical"))
5456 .cloned();
5457 let degraded = member
5458 .labels
5459 .get("console_degraded")
5460 .map(|value: &String| value == "true");
5461 let degraded_reason = member.labels.get("console_degraded_reason").cloned();
5462 let response_phase = match console_events {
5463 Some(store) => store.response_phase_for_identity(console_identity).await,
5464 None => None,
5465 };
5466 ConsoleAgentLiveSnapshot {
5467 agent_id: member.agent_identity.clone(),
5468 member_id: member.agent_identity.clone(),
5469 label,
5470 kind: "meerkat".to_string(),
5471 identity: Some(console_identity.to_string()),
5472 role: Some(member.role.clone()),
5473 state: Some(member.state.clone()),
5474 session_id: member.session_id.clone(),
5475 model_capabilities: member.model_capabilities.clone(),
5476 response_phase,
5477 watched,
5478 alert_level,
5479 degraded,
5480 degraded_reason,
5481 }
5482 })
5483 .collect::<Vec<_>>();
5484 let mut agents = join_all(agents).await;
5485 agents.sort_by(|left, right| left.label.cmp(&right.label));
5486 ConsoleLiveSnapshot::new(
5487 Some(runtime.handle().mob_id().to_string()),
5488 running,
5489 loaded_modules,
5490 agents,
5491 members,
5492 true,
5493 )
5494}
5495
5496async fn collect_console_snapshot_read_model(
5497 runtime: &MobRuntime,
5498) -> ConsoleSnapshotReadModelState {
5499 let handle = runtime.handle();
5500 let mut state = ConsoleSnapshotReadModelState {
5501 running: Some(matches!(
5502 handle.status_observation_snapshot(),
5503 MobState::Creating | MobState::Running
5504 )),
5505 ..ConsoleSnapshotReadModelState::default()
5506 };
5507 collect_console_session_index_for_handle(&handle, &mut state).await;
5508
5509 let (primary_members, _primary_owner_index) =
5515 project_console_members_from_handle(&handle, None, None, &state).await;
5516 state.primary_members = primary_members;
5517
5518 let Some(mcp_state) = runtime.agent_mob_mcp_state() else {
5519 return state;
5520 };
5521 let primary_mob_id = handle.mob_id().to_string();
5522 let mut processed = BTreeSet::from([primary_mob_id]);
5523 let mut delegate_groups: Vec<Vec<ConsoleMember>> = Vec::new();
5524 loop {
5525 let mut progressed = false;
5526 for (mob_id, delegate_handle) in mcp_state.mob_handles_snapshot().await {
5527 if processed.contains(mob_id.as_str()) {
5528 continue;
5529 }
5530 let Some(owner_session_id) = delegate_handle.definition().owner_bridge_session_index()
5531 else {
5532 processed.insert(mob_id.to_string());
5533 continue;
5534 };
5535 let Some(host_identity) = state.session_owner_by_id.get(owner_session_id).cloned()
5536 else {
5537 continue;
5538 };
5539 collect_console_session_index_for_handle(&delegate_handle, &mut state).await;
5540 let (delegate_members, _delegate_owner_index) = project_console_members_from_handle(
5541 &delegate_handle,
5542 Some(&host_identity),
5543 Some(mob_id.as_str()),
5544 &state,
5545 )
5546 .await;
5547 delegate_groups.push(delegate_members);
5548 processed.insert(mob_id.to_string());
5549 progressed = true;
5550 }
5551 if !progressed {
5552 break;
5553 }
5554 }
5555 state.delegate_member_groups = delegate_groups;
5556 state
5557}
5558
5559async fn collect_console_session_index_for_handle(
5560 handle: &MobHandle,
5561 state: &mut ConsoleSnapshotReadModelState,
5562) {
5563 for entry in handle.list_members_observation_snapshot().await {
5564 let identity = entry.agent_identity.to_string();
5565 let Some(session_id) = handle
5566 .resolve_bridge_session_id_observation(&entry.agent_identity)
5567 .await
5568 .map(|session_id| session_id.to_string())
5569 else {
5570 state.session_id_by_identity.remove(&identity);
5571 continue;
5572 };
5573 state
5574 .session_owner_by_id
5575 .insert(session_id.clone(), identity.clone());
5576 state.session_id_by_identity.insert(identity, session_id);
5577 }
5578}
5579
5580fn apply_console_visibility_policy(
5581 snapshot: &mut ConsoleLiveSnapshot,
5582 visibility_policy: &dyn ConsoleVisibilityPolicy,
5583) {
5584 let mut hidden = BTreeSet::new();
5585 snapshot.members.retain(|member| {
5586 let visible = visibility_policy.member_visible(member)
5587 && visibility_policy
5588 .identity_visible(&console_identity_record_from_console_member(member));
5589 if !visible {
5590 hidden.insert(member.agent_identity.clone());
5591 }
5592 visible
5593 });
5594 snapshot
5595 .agents
5596 .retain(|agent| !hidden.contains(&agent.agent_id));
5597 snapshot
5598 .loaded_modules
5599 .retain(|module_id| !hidden.contains(module_id));
5600}
5601
5602async fn reset_all_live_console_agents(
5603 runtime: &MobRuntime,
5604 console_events: Option<&ConsoleEventStore>,
5605 console_aggregator: Option<&MobKitConsoleAggregator>,
5606 identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
5607 visibility_policy: &dyn ConsoleVisibilityPolicy,
5608) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
5609 let read_model = ConsoleSnapshotReadModel::default();
5610 *read_model.inner.write().await = collect_console_snapshot_read_model(runtime).await;
5611 read_model
5614 .primed
5615 .store(true, std::sync::atomic::Ordering::Release);
5616 let snapshot =
5617 build_live_snapshot(runtime, &[], console_events, visibility_policy, &read_model).await;
5618 let raw_snapshot = build_live_snapshot(
5619 runtime,
5620 &[],
5621 console_events,
5622 &crate::console_aggregator::AllowAllConsoleVisibilityPolicy,
5623 &read_model,
5624 )
5625 .await;
5626 let identity_runtime_statuses = if let Some(identity_runtime) = identity_runtime {
5627 identity_runtime.statuses().await
5628 } else {
5629 Vec::new()
5630 };
5631 let identity_by_runtime_member_id = identity_runtime_statuses
5632 .iter()
5633 .filter_map(|status| {
5634 status
5635 .agent_runtime_id
5636 .as_ref()
5637 .map(|runtime_id| (runtime_id.as_str().to_string(), status.identity.to_string()))
5638 })
5639 .collect::<BTreeMap<_, _>>();
5640 let mut durable_identity_runtime_identities = identity_runtime_statuses
5641 .iter()
5642 .filter(|status| identity_status_visible_to_console(visibility_policy, status))
5643 .map(|status| status.identity.to_string())
5644 .collect::<BTreeSet<_>>();
5645 let mut main_identities = BTreeSet::new();
5646 let mut runtime_member_id_by_identity = BTreeMap::new();
5647 let mut runtime_member_ids_by_identity: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
5648 let mut session_id_by_identity_runtime_member: BTreeMap<(String, String), Option<String>> =
5649 BTreeMap::new();
5650 let mut live_alias_by_runtime_member_id: BTreeMap<String, (String, Option<String>)> =
5651 BTreeMap::new();
5652 let mut visible_runtime_member_ids = BTreeSet::new();
5653 let mut duplicate_live_identities = BTreeSet::new();
5654 let mut delegate_members = BTreeSet::new();
5655 for member in snapshot.members {
5656 if member.state == MEMBER_STATE_RETIRING {
5657 continue;
5658 }
5659 if let Some(source_mob_id) = member.labels.get("source_mob_id").cloned() {
5660 delegate_members.insert((source_mob_id, member.agent_identity));
5661 } else {
5662 let identity = member
5663 .labels
5664 .get("agent_identity")
5665 .filter(|value| !value.trim().is_empty())
5666 .cloned()
5667 .or_else(|| {
5668 identity_by_runtime_member_id
5669 .get(&member.agent_identity)
5670 .cloned()
5671 })
5672 .unwrap_or_else(|| member.agent_identity.clone());
5673 if let Some(existing) = runtime_member_id_by_identity.get(&identity)
5674 && existing != &member.agent_identity
5675 {
5676 duplicate_live_identities.insert(identity.clone());
5677 }
5678 runtime_member_ids_by_identity
5679 .entry(identity.clone())
5680 .or_default()
5681 .insert(member.agent_identity.clone());
5682 session_id_by_identity_runtime_member.insert(
5683 (identity.clone(), member.agent_identity.clone()),
5684 member.session_id.clone(),
5685 );
5686 live_alias_by_runtime_member_id.insert(
5687 member.agent_identity.clone(),
5688 (identity.clone(), member.session_id.clone()),
5689 );
5690 visible_runtime_member_ids.insert(member.agent_identity.clone());
5691 runtime_member_id_by_identity
5692 .entry(identity.clone())
5693 .or_insert(member.agent_identity);
5694 main_identities.insert(identity);
5695 }
5696 }
5697 let mut raw_runtime_member_ids_by_identity: BTreeMap<String, BTreeSet<String>> =
5698 BTreeMap::new();
5699 let mut raw_session_id_by_identity_runtime_member: BTreeMap<(String, String), Option<String>> =
5700 BTreeMap::new();
5701 let mut raw_live_alias_by_runtime_member_id: BTreeMap<String, (String, Option<String>)> =
5702 BTreeMap::new();
5703 for member in raw_snapshot.members {
5704 if member.state == MEMBER_STATE_RETIRING || member.labels.contains_key("source_mob_id") {
5705 continue;
5706 }
5707 let identity = member
5708 .labels
5709 .get("agent_identity")
5710 .filter(|value| !value.trim().is_empty())
5711 .cloned()
5712 .or_else(|| {
5713 identity_by_runtime_member_id
5714 .get(&member.agent_identity)
5715 .cloned()
5716 })
5717 .unwrap_or_else(|| member.agent_identity.clone());
5718 raw_runtime_member_ids_by_identity
5719 .entry(identity.clone())
5720 .or_default()
5721 .insert(member.agent_identity.clone());
5722 raw_session_id_by_identity_runtime_member.insert(
5723 (identity.clone(), member.agent_identity.clone()),
5724 member.session_id.clone(),
5725 );
5726 raw_live_alias_by_runtime_member_id
5727 .insert(member.agent_identity, (identity, member.session_id));
5728 }
5729 durable_identity_runtime_identities.retain(|identity| {
5730 identity_runtime_statuses
5731 .iter()
5732 .find(|status| status.identity.as_str() == identity)
5733 .and_then(|status| status.agent_runtime_id.as_ref())
5734 .is_none_or(|runtime_id| {
5735 let runtime_id = runtime_id.as_str();
5736 !raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
5737 || visible_runtime_member_ids.contains(runtime_id)
5738 })
5739 });
5740 let current_main_identities = main_identities.clone();
5741 let baseline_specs = runtime.baseline_member_specs().await;
5742 let baseline_identities = baseline_specs
5743 .iter()
5744 .filter(|spec| baseline_spec_visible_to_console(visibility_policy, spec))
5745 .map(|spec| spec.identity.to_string())
5746 .collect::<BTreeSet<_>>();
5747 main_identities.extend(baseline_identities.iter().cloned());
5748 main_identities.extend(durable_identity_runtime_identities.iter().cloned());
5749
5750 let mut retired_delegates = Vec::new();
5751 let mut reset_main = Vec::new();
5752 let mut retired_delegate_details = Vec::new();
5753 let mut reset_details = Vec::new();
5754 let mut failures = Vec::new();
5755 let mut warnings = Vec::new();
5756
5757 for identity in &main_identities {
5758 let parsed_identity = crate::identity_first::AgentIdentity::parse(identity).ok();
5759 let registered_status = if let (Some(identity_runtime), Some(parsed_identity)) =
5760 (identity_runtime, parsed_identity.as_ref())
5761 {
5762 identity_runtime.status(parsed_identity).await.ok()
5763 } else {
5764 None
5765 };
5766 let baseline_identity_runtime_registered = registered_status.is_some();
5767 if baseline_identities.contains(identity)
5768 && !current_main_identities.contains(identity)
5769 && !baseline_identity_runtime_registered
5770 {
5771 continue;
5772 }
5773 let registered_runtime_id = registered_status
5774 .as_ref()
5775 .and_then(|status| status.agent_runtime_id.as_ref())
5776 .map(crate::identity_first::AgentRuntimeId::as_str);
5777 let registered_visible = registered_runtime_id
5778 .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
5779 let registered_hidden = registered_runtime_id.is_some_and(|runtime_id| {
5780 raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
5781 && !visible_runtime_member_ids.contains(runtime_id)
5782 });
5783 if registered_hidden {
5784 continue;
5785 }
5786 if duplicate_live_identities.contains(identity) && !registered_visible {
5787 failures.push(json!({
5788 "identity": identity,
5789 "error": "ambiguous live identity alias",
5790 }));
5791 continue;
5792 }
5793 if let Some(status) = registered_status.as_ref() {
5794 if let Some(registered_runtime_id) = registered_runtime_id
5795 && let Some((live_identity, _live_session_id)) =
5796 raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
5797 && live_identity != identity
5798 {
5799 failures.push(json!({
5800 "identity": identity,
5801 "error": format!(
5802 "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
5803 ),
5804 "kind": "stale_live_identity_alias",
5805 }));
5806 continue;
5807 }
5808 if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(identity) {
5809 if !registered_runtime_id
5810 .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
5811 {
5812 failures.push(json!({
5813 "identity": identity,
5814 "error": format!(
5815 "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
5816 registered_runtime_id.unwrap_or("<none>"),
5817 live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
5818 ),
5819 "kind": "stale_live_identity_alias",
5820 }));
5821 continue;
5822 }
5823 if let Some(registered_runtime_id) = registered_runtime_id
5824 && let Some(registered_session_id) =
5825 status.session_id.as_ref().map(ToString::to_string)
5826 && let Some(Some(live_session_id)) = raw_session_id_by_identity_runtime_member
5827 .get(&(identity.clone(), registered_runtime_id.to_string()))
5828 && live_session_id != ®istered_session_id
5829 {
5830 failures.push(json!({
5831 "identity": identity,
5832 "error": format!(
5833 "stale live identity alias: identity runtime binding points at {registered_runtime_id} session {registered_session_id}, but live console alias resolves to session {live_session_id}"
5834 ),
5835 "kind": "stale_live_identity_alias",
5836 }));
5837 continue;
5838 }
5839 }
5840 if baseline_identities.contains(identity)
5841 && !identity_runtime
5842 .is_some_and(|identity_runtime| identity_runtime.has_session_bridge())
5843 {
5844 failures.push(json!({
5845 "identity": identity,
5846 "error": "reset requires an identity runtime with a session bridge",
5847 "kind": "identity_reset_requires_session_bridge",
5848 }));
5849 }
5850 continue;
5851 }
5852
5853 let runtime_member_id = runtime_member_id_by_identity
5854 .get(identity)
5855 .map(String::as_str)
5856 .unwrap_or(identity.as_str());
5857 if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
5858 && bound_identity != identity
5859 {
5860 failures.push(json!({
5861 "identity": identity,
5862 "error": format!(
5863 "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
5864 ),
5865 "kind": "stale_live_identity_alias",
5866 }));
5867 }
5868 }
5869
5870 if !failures.is_empty() {
5871 return Ok(json!({
5872 "reset": reset_main,
5873 "retired_delegates": retired_delegates,
5874 "reset_details": reset_details,
5875 "retired_delegate_details": retired_delegate_details,
5876 "warnings": warnings,
5877 "failed": failures,
5878 "startup_history": Value::Null,
5879 }));
5880 }
5881
5882 if let Some(state) = runtime.agent_mob_mcp_state() {
5883 for (mob_id, identity) in delegate_members {
5884 match state.handle_for(&MobId::from(mob_id.as_str())).await {
5885 Ok(handle) => {
5886 match retire_console_member(&handle, &MeerkatId::from(identity.as_str())).await
5887 {
5888 Ok(()) => {
5889 let detail = json!({
5890 "identity": identity,
5891 "mob_id": mob_id,
5892 });
5893 retired_delegates.push(detail.clone());
5894 retired_delegate_details.push(detail);
5895 }
5896 Err(err) => failures.push(json!({
5897 "identity": identity,
5898 "mob_id": mob_id,
5899 "error": err,
5900 })),
5901 }
5902 }
5903 Err(err) => failures.push(json!({
5904 "identity": identity,
5905 "mob_id": mob_id,
5906 "error": err.to_string(),
5907 })),
5908 }
5909 }
5910 } else if let Some(aggregator) = console_aggregator {
5911 let identities = delegate_members
5912 .into_iter()
5913 .map(|(_, identity)| identity)
5914 .collect::<BTreeSet<_>>();
5915 for identity in identities {
5916 match Box::pin(aggregator.retire_identity(&identity)).await {
5917 Ok(true) => {
5918 let detail = json!({ "identity": identity });
5919 retired_delegates.push(detail.clone());
5920 retired_delegate_details.push(detail);
5921 }
5922 Ok(false) => failures.push(json!({
5923 "identity": identity,
5924 "error": "unknown identity",
5925 })),
5926 Err(err) => failures.push(json!({
5927 "identity": identity,
5928 "error": err.to_string(),
5929 })),
5930 }
5931 }
5932 }
5933
5934 let handle = runtime.handle();
5935 for spec in baseline_specs {
5936 let identity = spec.identity.to_string();
5937 if !baseline_spec_visible_to_console(visibility_policy, &spec) {
5938 continue;
5939 }
5940 if current_main_identities.contains(&identity) {
5941 continue;
5942 }
5943 if let Some(identity_runtime) = identity_runtime
5944 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
5945 && identity_runtime.status(&parsed_identity).await.is_ok()
5946 {
5947 continue;
5948 }
5949 match handle.ensure_member(spec).await {
5950 Ok(_outcome) => {
5951 if let Some(store) = console_events {
5952 store
5953 .record_lifecycle(
5954 &identity,
5955 "identity_reset",
5956 json!({ "scope": "reset_all", "restored": true }),
5957 )
5958 .await;
5959 }
5960 reset_main.push(identity.clone());
5961 reset_details.push(json!({ "identity": identity }));
5962 }
5963 Err(err) => failures.push(json!({
5964 "identity": identity,
5965 "error": err.to_string(),
5966 })),
5967 }
5968 }
5969 for identity in main_identities {
5970 let baseline_identity_runtime_registered = if let Some(identity_runtime) = identity_runtime
5971 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
5972 {
5973 identity_runtime.status(&parsed_identity).await.is_ok()
5974 } else {
5975 false
5976 };
5977 if baseline_identities.contains(&identity)
5978 && !current_main_identities.contains(&identity)
5979 && !baseline_identity_runtime_registered
5980 {
5981 continue;
5982 }
5983 let registered_status = if let Some(identity_runtime) = identity_runtime
5984 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
5985 {
5986 identity_runtime.status(&parsed_identity).await.ok()
5987 } else {
5988 None
5989 };
5990 let registered_runtime_id = registered_status
5991 .as_ref()
5992 .and_then(|status| status.agent_runtime_id.as_ref())
5993 .map(crate::identity_first::AgentRuntimeId::as_str);
5994 let registered_visible = registered_runtime_id
5995 .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
5996 let registered_hidden = registered_runtime_id.is_some_and(|runtime_id| {
5997 raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
5998 && !visible_runtime_member_ids.contains(runtime_id)
5999 });
6000 if registered_hidden {
6001 continue;
6002 }
6003 if duplicate_live_identities.contains(&identity) && !registered_visible {
6004 failures.push(json!({
6005 "identity": identity,
6006 "error": "ambiguous live identity alias",
6007 }));
6008 continue;
6009 }
6010 if baseline_identities.contains(&identity) {
6011 if let Some(identity_runtime) = identity_runtime
6012 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
6013 && let Ok(status) = identity_runtime.status(&parsed_identity).await
6014 {
6015 let registered_runtime_id = status
6016 .agent_runtime_id
6017 .as_ref()
6018 .map(crate::identity_first::AgentRuntimeId::as_str);
6019 if let Some(registered_runtime_id) = registered_runtime_id
6020 && let Some((live_identity, _live_session_id)) =
6021 raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
6022 && live_identity != &identity
6023 {
6024 failures.push(json!({
6025 "identity": identity,
6026 "error": format!(
6027 "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
6028 ),
6029 "kind": "stale_live_identity_alias",
6030 }));
6031 continue;
6032 }
6033 if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(&identity) {
6034 if !registered_runtime_id
6035 .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
6036 {
6037 failures.push(json!({
6038 "identity": identity,
6039 "error": format!(
6040 "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
6041 registered_runtime_id.unwrap_or("<none>"),
6042 live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
6043 ),
6044 "kind": "stale_live_identity_alias",
6045 }));
6046 continue;
6047 }
6048 if let Some(registered_runtime_id) = registered_runtime_id
6049 && let Some(registered_session_id) =
6050 status.session_id.as_ref().map(ToString::to_string)
6051 && let Some(Some(live_session_id)) =
6052 raw_session_id_by_identity_runtime_member
6053 .get(&(identity.clone(), registered_runtime_id.to_string()))
6054 && live_session_id != ®istered_session_id
6055 {
6056 failures.push(json!({
6057 "identity": identity,
6058 "error": format!(
6059 "stale live identity alias: identity runtime binding points at {registered_runtime_id} session {registered_session_id}, but live console alias resolves to session {live_session_id}"
6060 ),
6061 "kind": "stale_live_identity_alias",
6062 }));
6063 continue;
6064 }
6065 }
6066 if !identity_runtime.has_session_bridge() {
6067 failures.push(json!({
6068 "identity": identity,
6069 "error": "reset requires an identity runtime with a session bridge",
6070 "kind": "identity_reset_requires_session_bridge",
6071 }));
6072 continue;
6073 }
6074 match identity_runtime.reset(&parsed_identity).await {
6075 Ok(record) => {
6076 match retire_stale_console_members_for_identity(
6077 &handle,
6078 visibility_policy,
6079 parsed_identity.as_str(),
6080 Some(record.agent_runtime_id.as_str()),
6081 )
6082 .await
6083 {
6084 Ok(()) => {
6085 reset_details.push(json!({ "identity": identity }));
6086 reset_main.push(identity);
6087 if let Some(store) = console_events {
6088 store
6089 .record_lifecycle(
6090 parsed_identity.as_str(),
6091 "identity_reset",
6092 json!({
6093 "scope": "reset_all",
6094 "generation": record.generation.get(),
6095 "checkpoint_version": record.checkpoint_version.get(),
6096 }),
6097 )
6098 .await;
6099 }
6100 }
6101 Err(err) => {
6102 warnings.push(json!({
6103 "identity": identity,
6104 "kind": "stale_member_cleanup_failed_after_identity_reset",
6105 "message": err,
6106 }));
6107 reset_details.push(json!({
6108 "identity": identity,
6109 "cleanup_warning": warnings.last().cloned(),
6110 }));
6111 reset_main.push(identity);
6112 if let Some(store) = console_events {
6113 store
6114 .record_lifecycle(
6115 parsed_identity.as_str(),
6116 "identity_reset",
6117 json!({
6118 "scope": "reset_all",
6119 "generation": record.generation.get(),
6120 "checkpoint_version": record.checkpoint_version.get(),
6121 "cleanup_warning": warnings.last().cloned(),
6122 }),
6123 )
6124 .await;
6125 }
6126 }
6127 }
6128 }
6129 Err(err) => failures.push(json!({
6130 "identity": identity,
6131 "error": err.to_string(),
6132 })),
6133 }
6134 continue;
6135 }
6136 let runtime_member_id = runtime_member_id_by_identity
6137 .get(&identity)
6138 .map(String::as_str)
6139 .unwrap_or(identity.as_str());
6140 if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
6141 && bound_identity != &identity
6142 {
6143 failures.push(json!({
6144 "identity": identity,
6145 "error": format!(
6146 "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
6147 ),
6148 "kind": "stale_live_identity_alias",
6149 }));
6150 continue;
6151 }
6152 match respawn_console_member(&handle, &MeerkatId::from(runtime_member_id)).await {
6153 Ok(()) => {
6154 if let Some(store) = console_events {
6155 store
6156 .record_lifecycle(
6157 &identity,
6158 "identity_reset",
6159 json!({ "scope": "reset_all" }),
6160 )
6161 .await;
6162 }
6163 reset_main.push(identity.clone());
6164 reset_details.push(json!({ "identity": identity }));
6165 }
6166 Err(err) => failures.push(json!({
6167 "identity": identity,
6168 "error": err,
6169 })),
6170 }
6171 } else {
6172 if let Some(identity_runtime) = identity_runtime
6173 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
6174 && let Ok(registered_status) = identity_runtime.status(&parsed_identity).await
6175 {
6176 let registered_runtime_id = registered_status
6177 .agent_runtime_id
6178 .as_ref()
6179 .map(crate::identity_first::AgentRuntimeId::as_str);
6180 let registered_visible = registered_runtime_id
6181 .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
6182 if duplicate_live_identities.contains(&identity) && !registered_visible {
6183 failures.push(json!({
6184 "identity": identity,
6185 "error": "ambiguous live identity alias",
6186 }));
6187 continue;
6188 }
6189 if let Some(registered_runtime_id) = registered_runtime_id
6190 && let Some((live_identity, _live_session_id)) =
6191 raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
6192 && live_identity != &identity
6193 {
6194 failures.push(json!({
6195 "identity": identity,
6196 "error": format!(
6197 "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
6198 ),
6199 "kind": "stale_live_identity_alias",
6200 }));
6201 continue;
6202 }
6203 if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(&identity) {
6204 if !registered_runtime_id
6205 .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
6206 {
6207 failures.push(json!({
6208 "identity": identity,
6209 "error": format!(
6210 "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
6211 registered_runtime_id.unwrap_or("<none>"),
6212 live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
6213 ),
6214 "kind": "stale_live_identity_alias",
6215 }));
6216 continue;
6217 }
6218 if let Some(registered_runtime_id) = registered_runtime_id
6219 && let Some(registered_session_id) = registered_status
6220 .session_id
6221 .as_ref()
6222 .map(ToString::to_string)
6223 && let Some(Some(live_session_id)) =
6224 raw_session_id_by_identity_runtime_member
6225 .get(&(identity.clone(), registered_runtime_id.to_string()))
6226 && live_session_id != ®istered_session_id
6227 {
6228 failures.push(json!({
6229 "identity": identity,
6230 "error": format!(
6231 "stale live identity alias: identity runtime binding points at {registered_runtime_id} session {registered_session_id}, but live console alias resolves to session {live_session_id}"
6232 ),
6233 "kind": "stale_live_identity_alias",
6234 }));
6235 continue;
6236 }
6237 }
6238 match identity_runtime.retire(&parsed_identity).await {
6239 Ok(token) => {
6240 let keep_runtime_member_id = registered_status
6241 .agent_runtime_id
6242 .as_ref()
6243 .filter(|_| identity_runtime.has_session_bridge())
6244 .map(crate::identity_first::AgentRuntimeId::as_str);
6245 match retire_stale_console_members_for_identity(
6246 &handle,
6247 visibility_policy,
6248 parsed_identity.as_str(),
6249 keep_runtime_member_id,
6250 )
6251 .await
6252 {
6253 Ok(()) => {
6254 retired_delegate_details.push(json!({ "identity": identity }));
6255 retired_delegates.push(json!({ "identity": identity }));
6256 if let Some(store) = console_events {
6257 store
6258 .record_lifecycle(
6259 parsed_identity.as_str(),
6260 "identity_retired",
6261 json!({
6262 "scope": "reset_all",
6263 "dynamic": true,
6264 "fencing_token": token.get(),
6265 }),
6266 )
6267 .await;
6268 }
6269 }
6270 Err(err) => {
6271 warnings.push(json!({
6272 "identity": identity,
6273 "kind": "stale_member_cleanup_failed_after_identity_retire",
6274 "message": err,
6275 }));
6276 retired_delegate_details.push(json!({
6277 "identity": identity,
6278 "cleanup_warning": warnings.last().cloned(),
6279 }));
6280 retired_delegates.push(json!({ "identity": identity }));
6281 if let Some(store) = console_events {
6282 store
6283 .record_lifecycle(
6284 parsed_identity.as_str(),
6285 "identity_retired",
6286 json!({
6287 "scope": "reset_all",
6288 "dynamic": true,
6289 "fencing_token": token.get(),
6290 "cleanup_warning": warnings.last().cloned(),
6291 }),
6292 )
6293 .await;
6294 }
6295 }
6296 }
6297 }
6298 Err(err) => failures.push(json!({
6299 "identity": identity,
6300 "error": err.to_string(),
6301 })),
6302 }
6303 continue;
6304 }
6305 let runtime_member_id = runtime_member_id_by_identity
6306 .get(&identity)
6307 .map(String::as_str)
6308 .unwrap_or(identity.as_str());
6309 if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
6310 && bound_identity != &identity
6311 {
6312 failures.push(json!({
6313 "identity": identity,
6314 "error": format!(
6315 "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
6316 ),
6317 "kind": "stale_live_identity_alias",
6318 }));
6319 continue;
6320 }
6321 match retire_console_member(&handle, &MeerkatId::from(runtime_member_id)).await {
6322 Ok(()) => {
6323 if let Some(store) = console_events {
6324 store
6325 .record_lifecycle(
6326 &identity,
6327 "identity_retired",
6328 json!({ "scope": "reset_all", "dynamic": true }),
6329 )
6330 .await;
6331 }
6332 retired_delegates.push(json!({ "identity": identity }));
6333 retired_delegate_details.push(json!({ "identity": identity }));
6334 }
6335 Err(err) => failures.push(json!({
6336 "identity": identity,
6337 "error": err,
6338 })),
6339 }
6340 }
6341 }
6342
6343 let startup_history = if failures.is_empty() {
6344 if let Some(aggregator) = console_aggregator {
6345 Box::pin(wait_for_reset_startup_history(
6346 aggregator,
6347 reset_main.iter().cloned().collect::<BTreeSet<_>>(),
6348 Duration::from_secs(10),
6349 ))
6350 .await
6351 .unwrap_or_else(|err| json!({ "error": err.to_string() }))
6352 } else {
6353 Value::Null
6354 }
6355 } else {
6356 Value::Null
6357 };
6358
6359 Ok(json!({
6360 "reset": reset_main,
6361 "retired_delegates": retired_delegates,
6362 "reset_details": reset_details,
6363 "retired_delegate_details": retired_delegate_details,
6364 "warnings": warnings,
6365 "failed": failures,
6366 "startup_history": startup_history,
6367 }))
6368}
6369
6370async fn wait_for_reset_startup_history(
6371 aggregator: &MobKitConsoleAggregator,
6372 identities: BTreeSet<String>,
6373 timeout: Duration,
6374) -> ConsoleLogResult<Value> {
6375 if identities.is_empty() {
6376 return Ok(json!({
6377 "timeout": false,
6378 "ready": Vec::<String>::new(),
6379 "pending": Vec::<String>::new(),
6380 }));
6381 }
6382
6383 let deadline = Instant::now() + timeout;
6384 let mut pending = identities;
6385 let mut ready = BTreeSet::new();
6386 while !pending.is_empty() {
6387 for identity in pending.clone() {
6388 let page = Box::pin(aggregator.query_timeline(ConsoleTimelineQuery {
6389 identity: Some(identity.clone()),
6390 limit: 1000,
6391 ..ConsoleTimelineQuery::default()
6392 }))
6393 .await?;
6394 let startup_completed = page.frames.iter().any(|frame| {
6395 matches!(
6396 frame.kind.as_str(),
6397 "interaction_complete" | "turn_completed"
6398 )
6399 });
6400 if startup_completed {
6401 pending.remove(&identity);
6402 ready.insert(identity);
6403 }
6404 }
6405
6406 if pending.is_empty() {
6407 break;
6408 }
6409 if Instant::now() >= deadline {
6410 return Ok(json!({
6411 "timeout": true,
6412 "ready": ready.into_iter().collect::<Vec<_>>(),
6413 "pending": pending.into_iter().collect::<Vec<_>>(),
6414 }));
6415 }
6416 tokio::time::sleep(Duration::from_millis(250)).await;
6417 }
6418
6419 Ok(json!({
6420 "timeout": false,
6421 "ready": ready.into_iter().collect::<Vec<_>>(),
6422 "pending": Vec::<String>::new(),
6423 }))
6424}
6425
6426fn dedupe_console_members_by_identity(members: &mut Vec<ConsoleMember>) {
6427 let mut seen_member_ids = BTreeSet::new();
6428 members.retain(|member| seen_member_ids.insert(member.agent_identity.clone()));
6429}
6430
6431fn console_member_console_identity(member: &ConsoleMember) -> &str {
6432 member
6433 .labels
6434 .get("agent_identity")
6435 .filter(|value| !value.trim().is_empty())
6436 .map_or(member.agent_identity.as_str(), String::as_str)
6437}
6438
6439fn console_identity_record_from_console_member(member: &ConsoleMember) -> ConsoleIdentityRecord {
6440 let identity = console_member_console_identity(member).to_string();
6441 let addressable = member
6442 .labels
6443 .get("addressable")
6444 .map(|value| !value.eq_ignore_ascii_case("false"))
6445 .unwrap_or(true)
6446 && member.state == MEMBER_STATE_ACTIVE;
6447 let visibility = if member.state == MEMBER_STATE_RETIRING {
6448 ConsoleVisibility::RetiredReadable
6449 } else if addressable {
6450 ConsoleVisibility::Addressable
6451 } else {
6452 ConsoleVisibility::Hidden
6453 };
6454 ConsoleIdentityRecord {
6455 identity: identity.clone(),
6456 display_name: member
6457 .labels
6458 .get("display_name")
6459 .cloned()
6460 .unwrap_or(identity),
6461 runtime_key: "runtime".to_string(),
6462 runtime_member_id: member.agent_identity.clone(),
6463 session_id: member.session_id.clone(),
6464 visibility,
6465 addressable,
6466 health: member.state.clone(),
6467 topology_peers: member.wired_to.clone(),
6468 labels: member.labels.clone(),
6469 }
6470}
6471
6472fn baseline_spec_visible_to_console(
6473 visibility_policy: &dyn ConsoleVisibilityPolicy,
6474 spec: &SpawnMemberSpec,
6475) -> bool {
6476 let mut labels = spec.labels.clone().unwrap_or_default();
6477 labels
6478 .entry("role".to_string())
6479 .or_insert_with(|| spec.role_name.to_string());
6480 let record = ConsoleIdentityRecord {
6481 identity: spec.identity.to_string(),
6482 display_name: spec.identity.to_string(),
6483 runtime_key: "baseline".to_string(),
6484 runtime_member_id: spec.identity.to_string(),
6485 session_id: None,
6486 visibility: ConsoleVisibility::Addressable,
6487 addressable: true,
6488 health: "baseline".to_string(),
6489 topology_peers: Vec::new(),
6490 labels,
6491 };
6492 let member = ConsoleMember {
6493 agent_identity: spec.identity.to_string(),
6494 role: spec.role_name.to_string(),
6495 state: MEMBER_STATE_ACTIVE.to_string(),
6496 model_capabilities: ConsoleModelCapabilities::default(),
6497 runtime_mode: spec
6498 .runtime_mode
6499 .as_ref()
6500 .map(std::string::ToString::to_string),
6501 session_id: None,
6502 wired_to: Vec::new(),
6503 labels: record.labels.clone(),
6504 };
6505 visibility_policy.member_visible(&member) && visibility_policy.identity_visible(&record)
6506}
6507
6508async fn project_console_members_from_handle(
6509 handle: &MobHandle,
6510 host_identity: Option<&str>,
6511 source_mob_id: Option<&str>,
6512 read_model: &ConsoleSnapshotReadModelState,
6513) -> (Vec<ConsoleMember>, BTreeMap<String, String>) {
6514 let entries = handle.list_all_members().await;
6515 let mut members = Vec::with_capacity(entries.len());
6516 let mut session_owner_by_id = BTreeMap::new();
6517 for entry in &entries {
6518 let identity = entry.agent_identity.to_string();
6519 let session_id = read_model.session_id_by_identity.get(&identity).cloned();
6520 if let Some(session_id) = session_id.as_ref() {
6521 session_owner_by_id.insert(session_id.clone(), identity.clone());
6522 }
6523 let model_capabilities =
6524 model_capabilities_for_role(handle.definition(), entry.role.as_str());
6525 let mut labels = entry.labels.clone();
6526 if let Some(host_identity) = host_identity {
6527 labels
6528 .entry("delegate_host_identity".to_string())
6529 .or_insert_with(|| host_identity.to_string());
6530 labels
6531 .entry("group".to_string())
6532 .or_insert_with(|| "Coordinators".to_string());
6533 }
6534 if let Some(source_mob_id) = source_mob_id {
6535 labels
6536 .entry("source_mob_id".to_string())
6537 .or_insert_with(|| source_mob_id.to_string());
6538 }
6539 let mut wired_to: Vec<String> = entry.wired_to.iter().map(ToString::to_string).collect();
6540 if let Some(host_identity) = host_identity
6541 && !wired_to.iter().any(|peer| peer == host_identity)
6542 {
6543 wired_to.push(host_identity.to_string());
6544 }
6545 members.push(ConsoleMember {
6546 agent_identity: identity,
6547 role: entry.role.to_string(),
6548 state: match entry.state {
6549 meerkat_mob::MemberState::Active => MEMBER_STATE_ACTIVE.to_string(),
6550 meerkat_mob::MemberState::Retiring => MEMBER_STATE_RETIRING.to_string(),
6551 },
6552 model_capabilities,
6553 runtime_mode: Some(entry.runtime_mode.to_string()),
6554 session_id,
6555 wired_to,
6556 labels,
6557 });
6558 }
6559 (members, session_owner_by_id)
6560}
6561
6562async fn build_aggregator_live_snapshot(
6563 aggregator: &MobKitConsoleAggregator,
6564 config_module_ids: &[String],
6565) -> Result<ConsoleLiveSnapshot, Box<dyn std::error::Error + Send + Sync>> {
6566 let identities = aggregator.list_identities().await?;
6567 let mut members = Vec::with_capacity(identities.len());
6568 for identity in &identities {
6569 let mut labels = identity.labels.clone();
6570 labels
6571 .entry("display_name".to_string())
6572 .or_insert_with(|| identity.display_name.clone());
6573 labels
6574 .entry("addressable".to_string())
6575 .or_insert_with(|| identity.addressable.to_string());
6576 let wired_to = identity.topology_peers.clone();
6580 members.push(ConsoleMember {
6581 agent_identity: identity.identity.clone(),
6582 role: labels
6583 .get("role")
6584 .cloned()
6585 .unwrap_or_else(|| "identity".to_string()),
6586 state: identity.health.clone(),
6587 model_capabilities: ConsoleModelCapabilities::default(),
6588 runtime_mode: Some("console_aggregator".to_string()),
6589 session_id: identity.session_id.clone(),
6590 wired_to,
6591 labels,
6592 });
6593 }
6594 members.sort_by(|left, right| left.agent_identity.cmp(&right.agent_identity));
6595 let agents = members
6596 .iter()
6597 .map(|member| ConsoleAgentLiveSnapshot {
6598 agent_id: member.agent_identity.clone(),
6599 member_id: member.agent_identity.clone(),
6600 label: member
6601 .labels
6602 .get("display_name")
6603 .cloned()
6604 .unwrap_or_else(|| member.agent_identity.clone()),
6605 kind: "meerkat".to_string(),
6606 identity: Some(member.agent_identity.clone()),
6607 role: Some(member.role.clone()),
6608 state: Some(member.state.clone()),
6609 session_id: member.session_id.clone(),
6610 model_capabilities: member.model_capabilities.clone(),
6611 response_phase: None,
6612 watched: None,
6613 alert_level: None,
6614 degraded: None,
6615 degraded_reason: None,
6616 })
6617 .collect::<Vec<_>>();
6618 let loaded_modules = if config_module_ids.is_empty() {
6619 members
6620 .iter()
6621 .map(|member| member.agent_identity.clone())
6622 .collect()
6623 } else {
6624 config_module_ids.to_vec()
6625 };
6626 Ok(ConsoleLiveSnapshot::new(
6627 Some("console-aggregator".to_string()),
6628 true,
6629 loaded_modules,
6630 agents,
6631 members,
6632 true,
6633 ))
6634}
6635
6636pub async fn console_frontend_index_handler() -> impl IntoResponse {
6637 (
6638 [
6639 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
6640 (header::CACHE_CONTROL, "no-store"),
6641 ],
6642 CONSOLE_FRONTEND_INDEX_HTML,
6643 )
6644}
6645
6646pub async fn console_frontend_app_js_handler() -> impl IntoResponse {
6647 (
6648 [
6649 (
6650 header::CONTENT_TYPE,
6651 "application/javascript; charset=utf-8",
6652 ),
6653 (header::CACHE_CONTROL, "no-store"),
6654 ],
6655 CONSOLE_FRONTEND_APP_JS,
6656 )
6657}
6658
6659pub async fn console_frontend_app_css_handler() -> impl IntoResponse {
6660 (
6661 [
6662 (header::CONTENT_TYPE, "text/css; charset=utf-8"),
6663 (header::CACHE_CONTROL, "no-store"),
6664 ],
6665 CONSOLE_FRONTEND_APP_CSS,
6666 )
6667}
6668
6669#[cfg(test)]
6670#[allow(clippy::expect_used, clippy::large_futures)]
6671mod tests {
6672 use super::ConsoleTimelineHttpQuery;
6673 use super::{
6674 ConsoleSnapshotReadModel, ConsoleSnapshotReadModelState, MAX_MULTIPART_BODY_BYTES,
6675 MAX_MULTIPART_IMAGE_BYTES, MultipartImageUpload, apply_console_visibility_policy,
6676 build_aggregator_live_snapshot, collect_console_snapshot_read_model,
6677 console_send_identity_first, console_send_with_identity_first_fallback,
6678 console_timeline_replay_unavailable_response, cursor_is_after,
6679 dedupe_console_members_by_identity, externalize_image_upload_placeholders,
6680 externalize_single_image_upload, handle_console_aggregator_rpc, handle_console_runtime_rpc,
6681 handle_console_runtime_rpc_with_visibility, member_id_matches_durable_identity,
6682 project_console_members_from_handle, query_timeline_snapshot, timeline_query_from_http,
6683 };
6684 use crate::blob_store::{BinaryBlobStore, ObjectStoreBlobStore};
6685 use crate::console_aggregator::{
6686 AllowAllConsoleVisibilityPolicy, ConsoleIdentityRecord,
6687 HideImplicitDelegateMembersConsoleVisibilityPolicy,
6688 };
6689 use crate::console_aggregator::{
6690 ConsoleCursor, ConsoleFrameSource, ConsoleFrameSourceKind, ConsoleFrameStatus,
6691 ConsoleTimelineQuery, ConsoleTimelineWindowQuery, ConsoleVisibilityPolicy,
6692 MobKitConsoleAggregator, NewConsoleFrame,
6693 };
6694 use crate::identity_first::contracts::{ContinuityStore, LeaseProvider};
6695 use crate::identity_first::{
6696 AgentAddressability, AgentBuildDraft, AgentIdentity, AgentRuntimeId, BridgeError,
6697 CheckpointVersion, ContinuityGeneration, ContinuityRecord, DurabilityPolicy,
6698 DurableAgentSpec, FencingToken, IdentityLifecycleState, IdentityRuntime,
6699 IdentityRuntimeConfig, LeaseAcquireResult, LeaseGrant, LocalContinuityStore,
6700 LocalLeaseProvider, ManagedPeerEdge, ResumeSessionOutcome, SessionBridge, SessionSnapshot,
6701 };
6702 use crate::mob_handle_runtime::{MobRuntime, model_capabilities_for_role};
6703 use crate::rpc::{JSONRPC_VERSION, JsonRpcRequest};
6704 use crate::runtime::{ConsoleAgentLiveSnapshot, ConsoleLiveSnapshot, ConsoleMember};
6705 use crate::unified_runtime::ConsoleEventStore;
6706 use crate::{MobBootstrapOptions, MobBootstrapSpec};
6707 use bytes::Bytes;
6708 use meerkat::{AgentFactory, Config, build_ephemeral_service};
6709 use meerkat_client::TestClient;
6710 use meerkat_core::types::HandlingMode;
6711 use meerkat_mob::ProfileName;
6712 use meerkat_mob::{MobDefinition, MobStorage, SpawnMemberSpec};
6713 use serde_json::{Value, json};
6714 use std::collections::BTreeMap;
6715 use std::sync::atomic::{AtomicUsize, Ordering};
6716 use std::sync::{Arc, Mutex};
6717 use std::time::Duration;
6718
6719 struct BlockingIdentityBridge {
6720 deliver_calls: Arc<AtomicUsize>,
6721 }
6722
6723 struct RecordingIdentityBridge {
6724 session_id: meerkat_core::types::SessionId,
6725 handling_modes: Arc<Mutex<Vec<HandlingMode>>>,
6726 }
6727
6728 #[async_trait::async_trait]
6729 impl SessionBridge for BlockingIdentityBridge {
6730 async fn create_session(
6731 &self,
6732 _identity: &AgentIdentity,
6733 _runtime_id: &AgentRuntimeId,
6734 _spec: &DurableAgentSpec,
6735 _draft: &AgentBuildDraft,
6736 session_id: &meerkat_core::types::SessionId,
6737 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
6738 Ok(session_id.clone())
6739 }
6740
6741 async fn resume_session(
6742 &self,
6743 _identity: &AgentIdentity,
6744 _runtime_id: &AgentRuntimeId,
6745 _spec: &DurableAgentSpec,
6746 _draft: &AgentBuildDraft,
6747 session_id: &meerkat_core::types::SessionId,
6748 _snapshot: &SessionSnapshot,
6749 ) -> Result<ResumeSessionOutcome, BridgeError> {
6750 Ok(ResumeSessionOutcome::Resumed {
6751 session_id: session_id.clone(),
6752 })
6753 }
6754
6755 async fn deliver(
6756 &self,
6757 _runtime_id: &AgentRuntimeId,
6758 _content: &meerkat_core::ContentInput,
6759 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
6760 self.deliver_calls.fetch_add(1, Ordering::SeqCst);
6761 std::future::pending().await
6762 }
6763
6764 async fn checkpoint_session(
6765 &self,
6766 _runtime_id: &AgentRuntimeId,
6767 _session_id: &meerkat_core::types::SessionId,
6768 ) -> Result<SessionSnapshot, BridgeError> {
6769 Err(BridgeError::Mob("checkpoint not used in test".to_string()))
6770 }
6771
6772 async fn retire_member(&self, _runtime_id: &AgentRuntimeId) -> Result<(), BridgeError> {
6773 Ok(())
6774 }
6775 }
6776
6777 #[async_trait::async_trait]
6778 impl SessionBridge for RecordingIdentityBridge {
6779 async fn create_session(
6780 &self,
6781 _identity: &AgentIdentity,
6782 _runtime_id: &AgentRuntimeId,
6783 _spec: &DurableAgentSpec,
6784 _draft: &AgentBuildDraft,
6785 session_id: &meerkat_core::types::SessionId,
6786 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
6787 Ok(session_id.clone())
6788 }
6789
6790 async fn resume_session(
6791 &self,
6792 _identity: &AgentIdentity,
6793 _runtime_id: &AgentRuntimeId,
6794 _spec: &DurableAgentSpec,
6795 _draft: &AgentBuildDraft,
6796 session_id: &meerkat_core::types::SessionId,
6797 _snapshot: &SessionSnapshot,
6798 ) -> Result<ResumeSessionOutcome, BridgeError> {
6799 Ok(ResumeSessionOutcome::Resumed {
6800 session_id: session_id.clone(),
6801 })
6802 }
6803
6804 async fn deliver(
6805 &self,
6806 runtime_id: &AgentRuntimeId,
6807 content: &meerkat_core::ContentInput,
6808 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
6809 self.deliver_with_mode(runtime_id, content, HandlingMode::Queue)
6810 .await
6811 }
6812
6813 async fn deliver_with_mode(
6814 &self,
6815 _runtime_id: &AgentRuntimeId,
6816 _content: &meerkat_core::ContentInput,
6817 handling_mode: HandlingMode,
6818 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
6819 self.handling_modes
6820 .lock()
6821 .map_err(|_| BridgeError::Mob("handling modes mutex poisoned".to_string()))?
6822 .push(handling_mode);
6823 Ok(self.session_id.clone())
6824 }
6825
6826 async fn checkpoint_session(
6827 &self,
6828 _runtime_id: &AgentRuntimeId,
6829 _session_id: &meerkat_core::types::SessionId,
6830 ) -> Result<SessionSnapshot, BridgeError> {
6831 Err(BridgeError::Mob("checkpoint not used in test".to_string()))
6832 }
6833
6834 async fn retire_member(&self, _runtime_id: &AgentRuntimeId) -> Result<(), BridgeError> {
6835 Ok(())
6836 }
6837 }
6838
6839 async fn build_empty_console_test_runtime(
6840 mob_id: &str,
6841 ) -> Result<(tempfile::TempDir, MobRuntime), Box<dyn std::error::Error + Send + Sync>> {
6842 let temp_dir = tempfile::tempdir()?;
6843 let session_path = temp_dir.path().join("sessions");
6844 std::fs::create_dir_all(&session_path)?;
6845 let factory = AgentFactory::new(&session_path).comms(true);
6846 let session_service = Arc::new(build_ephemeral_service(factory, Config::default(), 16));
6847 let definition = MobDefinition::from_toml(&format!(
6848 r#"
6849[mob]
6850id = "{mob_id}"
6851
6852[profiles.worker]
6853model = "gpt-5.5"
6854external_addressable = true
6855
6856[profiles.worker.tools]
6857comms = true
6858"#
6859 ))?;
6860 let runtime = MobRuntime::bootstrap(
6861 MobBootstrapSpec::new(definition, MobStorage::in_memory(), session_service)
6862 .with_options(MobBootstrapOptions {
6863 allow_ephemeral_sessions: true,
6864 notify_orchestrator_on_resume: true,
6865 default_llm_client: Some(Arc::new(TestClient::default())),
6866 }),
6867 )
6868 .await?;
6869 Ok((temp_dir, runtime))
6870 }
6871
6872 fn rpc_request(method: &str) -> JsonRpcRequest {
6873 JsonRpcRequest {
6874 jsonrpc: JSONRPC_VERSION.to_string(),
6875 id: Some(json!(1)),
6876 method: method.to_string(),
6877 params: json!({}),
6878 }
6879 }
6880
6881 fn rpc_request_with_params(method: &str, params: Value) -> JsonRpcRequest {
6882 JsonRpcRequest {
6883 jsonrpc: JSONRPC_VERSION.to_string(),
6884 id: Some(json!(1)),
6885 method: method.to_string(),
6886 params,
6887 }
6888 }
6889
6890 #[tokio::test]
6891 async fn console_runtime_identity_controls_resolve_durable_member_aliases()
6892 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
6893 let (_temp_dir, runtime) =
6894 build_empty_console_test_runtime("console-identity-control-alias").await?;
6895 let mut labels = BTreeMap::new();
6896 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
6897 labels.insert("display_name".to_string(), "Review Agent".to_string());
6898 runtime
6899 .handle()
6900 .spawn_spec(
6901 SpawnMemberSpec::from_wire(
6902 "worker".to_string(),
6903 "rt:review:singleton:0".to_string(),
6904 Some("You are Review Agent.".into()),
6905 None,
6906 None,
6907 )
6908 .with_labels(labels),
6909 )
6910 .await?;
6911
6912 let durable_status = Box::pin(handle_console_runtime_rpc(
6913 &runtime,
6914 None,
6915 None,
6916 None,
6917 None,
6918 None,
6919 None,
6920 None,
6921 None,
6922 rpc_request_with_params(
6923 "mobkit/status_identity",
6924 json!({ "identity": "review:singleton" }),
6925 ),
6926 true,
6927 ))
6928 .await;
6929 assert_eq!(durable_status["error"], Value::Null);
6930 assert_eq!(
6931 durable_status["result"]["identity"],
6932 json!("review:singleton")
6933 );
6934 assert_eq!(
6935 durable_status["result"]["agent_runtime_id"],
6936 json!("rt:review:singleton:0")
6937 );
6938
6939 let runtime_id_status = Box::pin(handle_console_runtime_rpc(
6940 &runtime,
6941 None,
6942 None,
6943 None,
6944 None,
6945 None,
6946 None,
6947 None,
6948 None,
6949 rpc_request_with_params(
6950 "mobkit/status_identity",
6951 json!({ "identity": "rt:review:singleton:0" }),
6952 ),
6953 true,
6954 ))
6955 .await;
6956 assert_eq!(runtime_id_status["error"], Value::Null);
6957 assert_eq!(
6958 runtime_id_status["result"]["identity"],
6959 json!("review:singleton")
6960 );
6961
6962 let runtime_id_inspect = Box::pin(handle_console_runtime_rpc(
6963 &runtime,
6964 None,
6965 None,
6966 None,
6967 None,
6968 None,
6969 None,
6970 None,
6971 None,
6972 rpc_request_with_params(
6973 "mobkit/inspect_identity",
6974 json!({ "identity": "rt:review:singleton:0" }),
6975 ),
6976 true,
6977 ))
6978 .await;
6979 assert_eq!(runtime_id_inspect["error"], Value::Null);
6980 assert_eq!(
6981 runtime_id_inspect["result"]["identity"],
6982 json!("review:singleton")
6983 );
6984
6985 let respawn = Box::pin(handle_console_runtime_rpc(
6986 &runtime,
6987 None,
6988 None,
6989 None,
6990 Some(ConsoleEventStore::new()),
6991 None,
6992 None,
6993 None,
6994 None,
6995 rpc_request_with_params("mobkit/respawn", json!({ "identity": "review:singleton" })),
6996 true,
6997 ))
6998 .await;
6999 assert_eq!(respawn["error"], Value::Null);
7000 assert_eq!(respawn["result"]["identity"], json!("review:singleton"));
7001 assert_eq!(
7002 respawn["result"]["agent_runtime_id"],
7003 json!("rt:review:singleton:0")
7004 );
7005
7006 let reset_without_identity_runtime = Box::pin(handle_console_runtime_rpc(
7007 &runtime,
7008 None,
7009 None,
7010 None,
7011 Some(ConsoleEventStore::new()),
7012 None,
7013 None,
7014 None,
7015 None,
7016 rpc_request_with_params("mobkit/reset", json!({ "identity": "review:singleton" })),
7017 true,
7018 ))
7019 .await;
7020 assert_ne!(reset_without_identity_runtime["error"], Value::Null);
7021 assert!(
7022 reset_without_identity_runtime["error"]["message"]
7023 .as_str()
7024 .unwrap_or_default()
7025 .contains("identity-first runtime required")
7026 );
7027
7028 let _ = runtime.handle().stop().await;
7029 Ok(())
7030 }
7031
7032 #[tokio::test]
7033 async fn console_runtime_identity_controls_reject_ambiguous_live_label_aliases()
7034 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
7035 let (_temp_dir, runtime) =
7036 build_empty_console_test_runtime("console-identity-ambiguous-live-alias").await?;
7037 for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
7038 let mut labels = BTreeMap::new();
7039 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
7040 runtime
7041 .handle()
7042 .spawn_spec(
7043 SpawnMemberSpec::from_wire(
7044 "worker".to_string(),
7045 runtime_id.to_string(),
7046 Some("You are a duplicate Review Agent.".into()),
7047 None,
7048 None,
7049 )
7050 .with_labels(labels),
7051 )
7052 .await?;
7053 }
7054
7055 for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
7056 for method in [
7057 "mobkit/status_identity",
7058 "mobkit/inspect_identity",
7059 "mobkit/retire",
7060 "mobkit/respawn",
7061 ] {
7062 let response = Box::pin(handle_console_runtime_rpc(
7063 &runtime,
7064 None,
7065 None,
7066 None,
7067 Some(ConsoleEventStore::new()),
7068 None,
7069 None,
7070 None,
7071 None,
7072 rpc_request_with_params(method, json!({ "identity": requested_identity })),
7073 true,
7074 ))
7075 .await;
7076 assert_ne!(
7077 response["error"],
7078 Value::Null,
7079 "{method} must reject ambiguous live alias for {requested_identity}"
7080 );
7081 assert_eq!(
7082 response["error"]["data"]["kind"],
7083 json!("ambiguous_live_identity_alias"),
7084 "unexpected response for {method}/{requested_identity}: {response:#?}"
7085 );
7086 }
7087 }
7088
7089 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
7090 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
7091 lease_provider: Arc::new(LocalLeaseProvider::new()),
7092 runtime_instance_id: "console-identity-ambiguous-live-alias".to_string(),
7093 has_runtime_store: true,
7094 durability_policy: DurabilityPolicy::SyncWriteThrough,
7095 bridge: None,
7096 default_timeout: None,
7097 }));
7098 for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
7099 for method in ["mobkit/reset", "mobkit/delete_identity"] {
7100 let response = Box::pin(handle_console_runtime_rpc(
7101 &runtime,
7102 None,
7103 None,
7104 None,
7105 Some(ConsoleEventStore::new()),
7106 None,
7107 Some(identity_runtime.clone()),
7108 None,
7109 None,
7110 rpc_request_with_params(method, json!({ "identity": requested_identity })),
7111 true,
7112 ))
7113 .await;
7114 assert_ne!(
7115 response["error"],
7116 Value::Null,
7117 "{method} must reject ambiguous live alias for {requested_identity}"
7118 );
7119 assert_eq!(
7120 response["error"]["data"]["kind"],
7121 json!("ambiguous_live_identity_alias"),
7122 "unexpected response for {method}/{requested_identity}: {response:#?}"
7123 );
7124 }
7125 }
7126
7127 let _ = runtime.handle().stop().await;
7128 Ok(())
7129 }
7130
7131 #[tokio::test]
7132 async fn console_runtime_durable_identity_prefers_registered_live_over_duplicate_labels()
7133 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
7134 let (_temp_dir, runtime) =
7135 build_empty_console_test_runtime("console-durable-wins-duplicate-live-labels").await?;
7136 for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
7137 let mut labels = BTreeMap::new();
7138 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
7139 runtime
7140 .handle()
7141 .spawn_spec(
7142 SpawnMemberSpec::from_wire(
7143 "worker".to_string(),
7144 runtime_id.to_string(),
7145 Some("You are a Review Agent candidate.".into()),
7146 None,
7147 None,
7148 )
7149 .with_labels(labels),
7150 )
7151 .await?;
7152 }
7153
7154 let store = Arc::new(LocalContinuityStore::in_memory()?);
7155 let lease_provider = Arc::new(LocalLeaseProvider::new());
7156 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
7157 continuity_store: store.clone(),
7158 lease_provider: lease_provider.clone(),
7159 runtime_instance_id: "test-runtime".to_string(),
7160 has_runtime_store: true,
7161 durability_policy: DurabilityPolicy::SyncWriteThrough,
7162 bridge: None,
7163 default_timeout: None,
7164 }));
7165 let identity = AgentIdentity::parse("review:singleton")?;
7166 let registered_session_id = runtime
7167 .handle()
7168 .resolve_bridge_session_id_observation(&meerkat_mob::ids::MeerkatId::from(
7169 "rt:review:singleton:0",
7170 ))
7171 .await
7172 .unwrap_or_else(meerkat_core::types::SessionId::new);
7173 let record = ContinuityRecord {
7174 identity: identity.clone(),
7175 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
7176 session_id: registered_session_id,
7177 generation: ContinuityGeneration::new(0),
7178 checkpoint_version: CheckpointVersion::new(0),
7179 };
7180 let grants = lease_provider
7181 .acquire_leases(std::slice::from_ref(&identity), "test-runtime")
7182 .await?;
7183 let grant = match grants.get(&identity).cloned() {
7184 Some(LeaseAcquireResult::Acquired(grant)) => grant,
7185 other => return Err(format!("expected acquired lease, got {other:?}").into()),
7186 };
7187 store
7188 .upsert_continuity_record(&record, grant.fencing_token)
7189 .await?;
7190 identity_runtime
7191 .register(
7192 DurableAgentSpec {
7193 identity: identity.clone(),
7194 profile: ProfileName::from("worker"),
7195 addressability: AgentAddressability::Addressable,
7196 display_name: None,
7197 labels: BTreeMap::new(),
7198 context: None,
7199 additional_instructions: Vec::new(),
7200 initial_message: None,
7201 runtime_mode_override: None,
7202 },
7203 IdentityLifecycleState::Active,
7204 Some(record),
7205 Some(grant),
7206 )
7207 .await;
7208
7209 for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
7210 for method in ["mobkit/status_identity", "mobkit/inspect_identity"] {
7211 let response = Box::pin(handle_console_runtime_rpc(
7212 &runtime,
7213 None,
7214 None,
7215 None,
7216 Some(ConsoleEventStore::new()),
7217 None,
7218 Some(identity_runtime.clone()),
7219 None,
7220 None,
7221 rpc_request_with_params(method, json!({ "identity": requested_identity })),
7222 true,
7223 ))
7224 .await;
7225 assert_eq!(
7226 response["error"],
7227 Value::Null,
7228 "{method} must use durable registered live binding despite duplicate labels for {requested_identity}: {response:#?}"
7229 );
7230 }
7231 }
7232 let reset_all_response = Box::pin(handle_console_runtime_rpc(
7233 &runtime,
7234 None,
7235 None,
7236 None,
7237 Some(ConsoleEventStore::new()),
7238 None,
7239 Some(identity_runtime.clone()),
7240 None,
7241 None,
7242 rpc_request("mobkit/reset_all"),
7243 true,
7244 ))
7245 .await;
7246 assert_eq!(
7247 reset_all_response["error"],
7248 Value::Null,
7249 "reset_all must also prefer the durable registered live binding despite duplicate labels: {reset_all_response:#?}"
7250 );
7251 assert!(
7252 reset_all_response["result"]["failed"]
7253 .as_array()
7254 .is_some_and(Vec::is_empty),
7255 "reset_all should not report duplicate-label failure for durable registered binding: {reset_all_response:#?}"
7256 );
7257
7258 let _ = runtime.handle().stop().await;
7259 Ok(())
7260 }
7261
7262 #[tokio::test]
7263 async fn console_runtime_identity_controls_reject_wrong_projected_live_only_alias()
7264 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
7265 let (_temp_dir, runtime) =
7266 build_empty_console_test_runtime("console-identity-wrong-projected-live-only").await?;
7267
7268 let mut labels = BTreeMap::new();
7269 labels.insert("agent_identity".to_string(), "other:singleton".to_string());
7270 runtime
7271 .handle()
7272 .spawn_spec(
7273 SpawnMemberSpec::from_wire(
7274 "worker".to_string(),
7275 "rt:review:singleton:0".to_string(),
7276 Some("You are a wrong-projected Review Agent.".into()),
7277 None,
7278 None,
7279 )
7280 .with_labels(labels),
7281 )
7282 .await?;
7283
7284 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
7285 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
7286 lease_provider: Arc::new(LocalLeaseProvider::new()),
7287 runtime_instance_id: "console-identity-wrong-projected-live-only".to_string(),
7288 has_runtime_store: true,
7289 durability_policy: DurabilityPolicy::SyncWriteThrough,
7290 bridge: None,
7291 default_timeout: None,
7292 }));
7293 let identity = AgentIdentity::parse("review:singleton")?;
7294 let record = ContinuityRecord {
7295 identity: identity.clone(),
7296 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
7297 session_id: meerkat_core::types::SessionId::new(),
7298 generation: ContinuityGeneration::new(0),
7299 checkpoint_version: CheckpointVersion::new(0),
7300 };
7301 identity_runtime
7302 .register(
7303 DurableAgentSpec {
7304 identity: identity.clone(),
7305 profile: ProfileName::from("worker"),
7306 addressability: AgentAddressability::Addressable,
7307 display_name: None,
7308 labels: BTreeMap::new(),
7309 context: None,
7310 additional_instructions: Vec::new(),
7311 initial_message: None,
7312 runtime_mode_override: None,
7313 },
7314 IdentityLifecycleState::Active,
7315 Some(record),
7316 Some(LeaseGrant {
7317 identity,
7318 fencing_token: FencingToken::new(1),
7319 ttl: Duration::from_mins(1),
7320 }),
7321 )
7322 .await;
7323
7324 for method in [
7325 "mobkit/status_identity",
7326 "mobkit/inspect_identity",
7327 "mobkit/retire",
7328 ] {
7329 let response = Box::pin(handle_console_runtime_rpc(
7330 &runtime,
7331 None,
7332 None,
7333 None,
7334 Some(ConsoleEventStore::new()),
7335 None,
7336 Some(identity_runtime.clone()),
7337 None,
7338 None,
7339 rpc_request_with_params(method, json!({ "identity": "other:singleton" })),
7340 true,
7341 ))
7342 .await;
7343 assert_ne!(
7344 response["error"],
7345 Value::Null,
7346 "{method} must reject wrong-projected live-only alias"
7347 );
7348 assert_eq!(
7349 response["error"]["data"]["kind"],
7350 json!("stale_live_identity_alias"),
7351 "unexpected response for {method}: {response:#?}"
7352 );
7353 }
7354 assert!(
7355 runtime
7356 .handle()
7357 .get_member(&meerkat_mob::ids::MeerkatId::from("rt:review:singleton:0"))
7358 .await
7359 .is_some(),
7360 "wrong-projected durable runtime member must not be retired through projected alias"
7361 );
7362
7363 let _ = runtime.handle().stop().await;
7364 Ok(())
7365 }
7366
7367 #[derive(Debug)]
7368 struct HideIdentityPolicy(&'static str);
7369
7370 impl ConsoleVisibilityPolicy for HideIdentityPolicy {
7371 fn identity_visible(&self, record: &ConsoleIdentityRecord) -> bool {
7372 record.identity != self.0
7373 }
7374 }
7375
7376 #[derive(Debug)]
7377 struct HideMemberPolicy(&'static str);
7378
7379 impl ConsoleVisibilityPolicy for HideMemberPolicy {
7380 fn member_visible(&self, member: &ConsoleMember) -> bool {
7381 member.agent_identity != self.0
7382 && member
7383 .labels
7384 .get("agent_identity")
7385 .is_none_or(|identity| identity != self.0)
7386 }
7387
7388 fn identity_visible(&self, record: &ConsoleIdentityRecord) -> bool {
7389 record.runtime_member_id != self.0
7390 }
7391 }
7392
7393 #[derive(Debug)]
7394 struct HideOnlyMemberPolicy(&'static str);
7395
7396 impl ConsoleVisibilityPolicy for HideOnlyMemberPolicy {
7397 fn member_visible(&self, member: &ConsoleMember) -> bool {
7398 member.agent_identity != self.0
7399 }
7400 }
7401
7402 #[tokio::test]
7403 async fn console_runtime_identity_controls_respect_visibility_policy()
7404 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
7405 let (_temp_dir, runtime) =
7406 build_empty_console_test_runtime("console-identity-hidden-controls").await?;
7407 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
7408 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
7409 lease_provider: Arc::new(LocalLeaseProvider::new()),
7410 runtime_instance_id: "console-identity-hidden-controls".to_string(),
7411 has_runtime_store: true,
7412 durability_policy: DurabilityPolicy::SyncWriteThrough,
7413 bridge: None,
7414 default_timeout: None,
7415 }));
7416 let identity = AgentIdentity::parse("review:singleton")?;
7417 let record = ContinuityRecord {
7418 identity: identity.clone(),
7419 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
7420 session_id: meerkat_core::types::SessionId::new(),
7421 generation: ContinuityGeneration::new(0),
7422 checkpoint_version: CheckpointVersion::new(0),
7423 };
7424 identity_runtime
7425 .register(
7426 DurableAgentSpec {
7427 identity: identity.clone(),
7428 profile: ProfileName::from("worker"),
7429 addressability: AgentAddressability::Addressable,
7430 display_name: None,
7431 labels: BTreeMap::new(),
7432 context: None,
7433 additional_instructions: Vec::new(),
7434 initial_message: None,
7435 runtime_mode_override: None,
7436 },
7437 IdentityLifecycleState::Active,
7438 Some(record),
7439 Some(LeaseGrant {
7440 identity: identity.clone(),
7441 fencing_token: FencingToken::new(7),
7442 ttl: Duration::from_mins(1),
7443 }),
7444 )
7445 .await;
7446
7447 for method in [
7448 "mobkit/status_identity",
7449 "mobkit/inspect_identity",
7450 "mobkit/retire",
7451 "mobkit/respawn",
7452 "mobkit/reset",
7453 "mobkit/delete_identity",
7454 ] {
7455 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
7456 &runtime,
7457 None,
7458 None,
7459 None,
7460 Some(ConsoleEventStore::new()),
7461 None,
7462 Some(identity_runtime.clone()),
7463 None,
7464 None,
7465 &HideIdentityPolicy("review:singleton"),
7466 rpc_request_with_params(method, json!({ "identity": "review:singleton" })),
7467 true,
7468 ))
7469 .await;
7470 assert_ne!(
7471 response["error"],
7472 Value::Null,
7473 "{method} must reject hidden durable identity"
7474 );
7475 assert_eq!(
7476 response["error"]["data"]["kind"],
7477 json!("identity_hidden_by_policy"),
7478 "unexpected hidden response for {method}: {response:#?}"
7479 );
7480 }
7481 identity_runtime
7482 .status(&AgentIdentity::parse("review:singleton")?)
7483 .await
7484 .expect("hidden control RPCs must not mutate the durable identity");
7485
7486 let _ = runtime.handle().stop().await;
7487 Ok(())
7488 }
7489
7490 #[tokio::test]
7491 async fn console_runtime_durable_identity_controls_reject_hidden_bound_member()
7492 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
7493 let (_temp_dir, runtime) =
7494 build_empty_console_test_runtime("console-durable-hidden-bound-member").await?;
7495 runtime
7496 .handle()
7497 .spawn_spec(SpawnMemberSpec::from_wire(
7498 "worker".to_string(),
7499 "rt:review:singleton:0".to_string(),
7500 Some("You are the live Review Agent.".into()),
7501 None,
7502 None,
7503 ))
7504 .await?;
7505
7506 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
7507 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
7508 lease_provider: Arc::new(LocalLeaseProvider::new()),
7509 runtime_instance_id: "console-durable-hidden-bound-member".to_string(),
7510 has_runtime_store: true,
7511 durability_policy: DurabilityPolicy::SyncWriteThrough,
7512 bridge: None,
7513 default_timeout: None,
7514 }));
7515 let identity = AgentIdentity::parse("review:singleton")?;
7516 let record = ContinuityRecord {
7517 identity: identity.clone(),
7518 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
7519 session_id: meerkat_core::types::SessionId::new(),
7520 generation: ContinuityGeneration::new(0),
7521 checkpoint_version: CheckpointVersion::new(0),
7522 };
7523 identity_runtime
7524 .register(
7525 DurableAgentSpec {
7526 identity: identity.clone(),
7527 profile: ProfileName::from("worker"),
7528 addressability: AgentAddressability::Addressable,
7529 display_name: None,
7530 labels: BTreeMap::new(),
7531 context: None,
7532 additional_instructions: Vec::new(),
7533 initial_message: None,
7534 runtime_mode_override: None,
7535 },
7536 IdentityLifecycleState::Active,
7537 Some(record),
7538 Some(LeaseGrant {
7539 identity: identity.clone(),
7540 fencing_token: FencingToken::new(7),
7541 ttl: Duration::from_mins(1),
7542 }),
7543 )
7544 .await;
7545
7546 for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
7547 for method in [
7548 "mobkit/status_identity",
7549 "mobkit/inspect_identity",
7550 "mobkit/retire",
7551 "mobkit/respawn",
7552 "mobkit/reset",
7553 "mobkit/delete_identity",
7554 ] {
7555 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
7556 &runtime,
7557 None,
7558 None,
7559 None,
7560 Some(ConsoleEventStore::new()),
7561 None,
7562 Some(identity_runtime.clone()),
7563 None,
7564 None,
7565 &HideOnlyMemberPolicy("rt:review:singleton:0"),
7566 rpc_request_with_params(method, json!({ "identity": requested_identity })),
7567 true,
7568 ))
7569 .await;
7570 assert_eq!(
7571 response["error"]["data"]["kind"],
7572 json!("identity_hidden_by_policy"),
7573 "durable {method} must reject hidden bound live member for {requested_identity}: {response:#?}"
7574 );
7575 }
7576 }
7577
7578 let _ = runtime.handle().stop().await;
7579 Ok(())
7580 }
7581
7582 #[tokio::test]
7583 async fn console_runtime_live_only_identity_controls_respect_visibility_policy()
7584 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
7585 let (_temp_dir, runtime) =
7586 build_empty_console_test_runtime("console-live-only-hidden-controls").await?;
7587
7588 let mut labels = BTreeMap::new();
7589 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
7590 runtime
7591 .handle()
7592 .spawn_spec(
7593 SpawnMemberSpec::from_wire(
7594 "worker".to_string(),
7595 "rt:review:singleton:0".to_string(),
7596 Some("You are the live Review Agent.".into()),
7597 None,
7598 None,
7599 )
7600 .with_labels(labels),
7601 )
7602 .await?;
7603
7604 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
7605 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
7606 lease_provider: Arc::new(LocalLeaseProvider::new()),
7607 runtime_instance_id: "console-live-only-hidden-controls".to_string(),
7608 has_runtime_store: true,
7609 durability_policy: DurabilityPolicy::SyncWriteThrough,
7610 bridge: None,
7611 default_timeout: None,
7612 }));
7613
7614 for method in [
7615 "mobkit/status_identity",
7616 "mobkit/inspect_identity",
7617 "mobkit/retire",
7618 "mobkit/respawn",
7619 "mobkit/reset",
7620 "mobkit/delete_identity",
7621 ] {
7622 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
7623 &runtime,
7624 None,
7625 None,
7626 None,
7627 Some(ConsoleEventStore::new()),
7628 None,
7629 Some(identity_runtime.clone()),
7630 None,
7631 None,
7632 &HideMemberPolicy("rt:review:singleton:0"),
7633 rpc_request_with_params(method, json!({ "identity": "review:singleton" })),
7634 true,
7635 ))
7636 .await;
7637 assert_ne!(
7638 response["error"],
7639 Value::Null,
7640 "{method} must reject hidden live-only identity"
7641 );
7642 assert_eq!(
7643 response["error"]["data"]["kind"],
7644 json!("identity_hidden_by_policy"),
7645 "unexpected hidden live-only response for {method}: {response:#?}"
7646 );
7647 }
7648 assert!(
7649 runtime
7650 .handle()
7651 .get_member(&meerkat_mob::ids::MeerkatId::from("rt:review:singleton:0"))
7652 .await
7653 .is_some(),
7654 "hidden live-only controls must not mutate the live member"
7655 );
7656
7657 let _ = runtime.handle().stop().await;
7658 Ok(())
7659 }
7660
7661 #[tokio::test]
7662 async fn console_runtime_reset_live_only_alias_without_session_bridge_uses_live_fallback()
7663 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
7664 let (_temp_dir, runtime) =
7665 build_empty_console_test_runtime("console-reset-live-only-no-bridge").await?;
7666
7667 let mut labels = BTreeMap::new();
7668 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
7669 runtime
7670 .handle()
7671 .spawn_spec(
7672 SpawnMemberSpec::from_wire(
7673 "worker".to_string(),
7674 "rt:review:singleton:0".to_string(),
7675 Some("You are the live Review Agent.".into()),
7676 None,
7677 None,
7678 )
7679 .with_labels(labels),
7680 )
7681 .await?;
7682
7683 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
7684 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
7685 lease_provider: Arc::new(LocalLeaseProvider::new()),
7686 runtime_instance_id: "console-reset-live-only-no-bridge".to_string(),
7687 has_runtime_store: true,
7688 durability_policy: DurabilityPolicy::SyncWriteThrough,
7689 bridge: None,
7690 default_timeout: None,
7691 }));
7692
7693 let response = Box::pin(handle_console_runtime_rpc(
7694 &runtime,
7695 None,
7696 None,
7697 None,
7698 Some(ConsoleEventStore::new()),
7699 None,
7700 Some(identity_runtime),
7701 None,
7702 None,
7703 rpc_request_with_params("mobkit/reset", json!({ "identity": "review:singleton" })),
7704 true,
7705 ))
7706 .await;
7707 assert_eq!(
7708 response["error"],
7709 Value::Null,
7710 "live-only reset should use live fallback instead of requiring session bridge: {response:#?}"
7711 );
7712 assert_eq!(response["result"]["identity"], json!("review:singleton"));
7713
7714 let _ = runtime.handle().stop().await;
7715 Ok(())
7716 }
7717
7718 #[tokio::test]
7719 async fn reset_all_rejects_registered_runtime_projected_under_wrong_identity()
7720 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
7721 let (_temp_dir, runtime) =
7722 build_empty_console_test_runtime("console-reset-all-stale-projection").await?;
7723
7724 let mut labels = BTreeMap::new();
7725 labels.insert("agent_identity".to_string(), "other:singleton".to_string());
7726 runtime
7727 .handle()
7728 .spawn_spec(
7729 SpawnMemberSpec::from_wire(
7730 "worker".to_string(),
7731 "rt:review:singleton:0".to_string(),
7732 Some("You are a mislabeled Review Agent.".into()),
7733 None,
7734 None,
7735 )
7736 .with_labels(labels),
7737 )
7738 .await?;
7739
7740 let store = Arc::new(LocalContinuityStore::in_memory()?);
7741 let lease_provider = Arc::new(LocalLeaseProvider::new());
7742 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
7743 continuity_store: store.clone(),
7744 lease_provider: lease_provider.clone(),
7745 runtime_instance_id: "test-runtime".to_string(),
7746 has_runtime_store: true,
7747 durability_policy: DurabilityPolicy::SyncWriteThrough,
7748 bridge: None,
7749 default_timeout: None,
7750 }));
7751 let identity = AgentIdentity::parse("review:singleton")?;
7752 let record = ContinuityRecord {
7753 identity: identity.clone(),
7754 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
7755 session_id: meerkat_core::types::SessionId::new(),
7756 generation: ContinuityGeneration::new(0),
7757 checkpoint_version: CheckpointVersion::new(0),
7758 };
7759 let grants = lease_provider
7760 .acquire_leases(std::slice::from_ref(&identity), "test-runtime")
7761 .await?;
7762 let grant = match grants.get(&identity).cloned() {
7763 Some(LeaseAcquireResult::Acquired(grant)) => grant,
7764 other => return Err(format!("expected acquired lease, got {other:?}").into()),
7765 };
7766 store
7767 .upsert_continuity_record(&record, grant.fencing_token)
7768 .await?;
7769 identity_runtime
7770 .register(
7771 DurableAgentSpec {
7772 identity: identity.clone(),
7773 profile: ProfileName::from("worker"),
7774 addressability: AgentAddressability::Addressable,
7775 display_name: None,
7776 labels: BTreeMap::new(),
7777 context: None,
7778 additional_instructions: Vec::new(),
7779 initial_message: None,
7780 runtime_mode_override: None,
7781 },
7782 IdentityLifecycleState::Active,
7783 Some(record),
7784 Some(grant),
7785 )
7786 .await;
7787
7788 let response = Box::pin(handle_console_runtime_rpc(
7789 &runtime,
7790 None,
7791 None,
7792 None,
7793 Some(ConsoleEventStore::new()),
7794 None,
7795 Some(identity_runtime),
7796 None,
7797 None,
7798 rpc_request("mobkit/reset_all"),
7799 true,
7800 ))
7801 .await;
7802 assert_ne!(response["error"], Value::Null);
7803 let failed = response["error"]["data"]["failed"]
7804 .as_array()
7805 .expect("reset_all should report failed identities");
7806 let stale_failure = failed
7807 .iter()
7808 .find(|failure| failure["identity"] == json!("review:singleton"))
7809 .expect("review identity should fail stale alias validation");
7810 assert_eq!(
7811 stale_failure["kind"],
7812 json!("stale_live_identity_alias"),
7813 "unexpected reset_all response: {response:#?}"
7814 );
7815 assert!(
7816 stale_failure["error"]
7817 .as_str()
7818 .unwrap_or_default()
7819 .contains("projects identity other:singleton"),
7820 "unexpected stale failure: {stale_failure:#?}"
7821 );
7822 let retired = response["error"]["data"]["retired_delegates"]
7823 .as_array()
7824 .expect("reset_all should return retired delegates");
7825 assert!(
7826 !retired
7827 .iter()
7828 .any(|entry| entry["identity"] == json!("other:singleton")),
7829 "wrong-projected live alias must not be destructively retired before stale validation; response: {response:#?}"
7830 );
7831 assert!(
7832 runtime
7833 .handle()
7834 .get_member(&meerkat_mob::ids::MeerkatId::from("rt:review:singleton:0",))
7835 .await
7836 .is_some(),
7837 "wrong-projected durable runtime member must remain present after reset_all rejection"
7838 );
7839
7840 let _ = runtime.handle().stop().await;
7841 Ok(())
7842 }
7843
7844 #[tokio::test]
7845 async fn reset_all_respects_console_visibility_policy_for_live_members()
7846 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
7847 let (_temp_dir, runtime) =
7848 build_empty_console_test_runtime("console-reset-all-hidden-live").await?;
7849
7850 runtime
7851 .handle()
7852 .spawn_spec(
7853 SpawnMemberSpec::from_wire(
7854 "worker".to_string(),
7855 "hidden:singleton".to_string(),
7856 Some("You are hidden from console lifecycle controls.".into()),
7857 None,
7858 None,
7859 )
7860 .with_labels(BTreeMap::from([(
7861 "agent_identity".to_string(),
7862 "hidden:singleton".to_string(),
7863 )])),
7864 )
7865 .await?;
7866
7867 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
7868 &runtime,
7869 None,
7870 None,
7871 None,
7872 Some(ConsoleEventStore::new()),
7873 None,
7874 None,
7875 None,
7876 None,
7877 &HideMemberPolicy("hidden:singleton"),
7878 rpc_request("mobkit/reset_all"),
7879 true,
7880 ))
7881 .await;
7882 assert_eq!(
7883 response["error"],
7884 Value::Null,
7885 "hidden live member should be outside reset_all target set: {response:#?}"
7886 );
7887 assert!(
7888 runtime
7889 .handle()
7890 .get_member(&meerkat_mob::ids::MeerkatId::from("hidden:singleton"))
7891 .await
7892 .is_some(),
7893 "reset_all must not retire hidden live members"
7894 );
7895
7896 let _ = runtime.handle().stop().await;
7897 Ok(())
7898 }
7899
7900 #[tokio::test]
7901 async fn reset_all_skips_durable_identity_with_hidden_bound_member()
7902 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
7903 let (_temp_dir, runtime) =
7904 build_empty_console_test_runtime("console-reset-all-hidden-durable-bound").await?;
7905 runtime
7906 .handle()
7907 .spawn_spec(SpawnMemberSpec::from_wire(
7908 "worker".to_string(),
7909 "rt:review:singleton:0".to_string(),
7910 Some("You are the hidden Review Agent.".into()),
7911 None,
7912 None,
7913 ))
7914 .await?;
7915
7916 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
7917 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
7918 lease_provider: Arc::new(LocalLeaseProvider::new()),
7919 runtime_instance_id: "console-reset-all-hidden-durable-bound".to_string(),
7920 has_runtime_store: true,
7921 durability_policy: DurabilityPolicy::SyncWriteThrough,
7922 bridge: None,
7923 default_timeout: None,
7924 }));
7925 let identity = AgentIdentity::parse("review:singleton")?;
7926 identity_runtime
7927 .register(
7928 DurableAgentSpec {
7929 identity: identity.clone(),
7930 profile: ProfileName::from("worker"),
7931 addressability: AgentAddressability::Addressable,
7932 display_name: None,
7933 labels: BTreeMap::new(),
7934 context: None,
7935 additional_instructions: Vec::new(),
7936 initial_message: None,
7937 runtime_mode_override: None,
7938 },
7939 IdentityLifecycleState::Active,
7940 Some(ContinuityRecord {
7941 identity: identity.clone(),
7942 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
7943 session_id: meerkat_core::types::SessionId::new(),
7944 generation: ContinuityGeneration::new(0),
7945 checkpoint_version: CheckpointVersion::new(0),
7946 }),
7947 Some(LeaseGrant {
7948 identity: identity.clone(),
7949 fencing_token: FencingToken::new(9),
7950 ttl: Duration::from_mins(1),
7951 }),
7952 )
7953 .await;
7954
7955 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
7956 &runtime,
7957 None,
7958 None,
7959 None,
7960 Some(ConsoleEventStore::new()),
7961 None,
7962 Some(identity_runtime.clone()),
7963 None,
7964 None,
7965 &HideOnlyMemberPolicy("rt:review:singleton:0"),
7966 rpc_request("mobkit/reset_all"),
7967 true,
7968 ))
7969 .await;
7970 assert_eq!(
7971 response["error"],
7972 Value::Null,
7973 "hidden durable bound member should be outside reset_all target set: {response:#?}"
7974 );
7975 assert_eq!(
7976 identity_runtime.status(&identity).await?.state,
7977 IdentityLifecycleState::Active
7978 );
7979
7980 let _ = runtime.handle().stop().await;
7981 Ok(())
7982 }
7983
7984 #[tokio::test]
7985 async fn identity_lifecycle_cleanup_skips_hidden_projected_duplicates()
7986 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
7987 let (_temp_dir, runtime) =
7988 build_empty_console_test_runtime("console-hidden-stale-duplicate-cleanup").await?;
7989 for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
7990 runtime
7991 .handle()
7992 .spawn_spec(
7993 SpawnMemberSpec::from_wire(
7994 "worker".to_string(),
7995 runtime_id.to_string(),
7996 Some("You are a Review Agent candidate.".into()),
7997 None,
7998 None,
7999 )
8000 .with_labels(BTreeMap::from([(
8001 "agent_identity".to_string(),
8002 "review:singleton".to_string(),
8003 )])),
8004 )
8005 .await?;
8006 }
8007
8008 let store = Arc::new(LocalContinuityStore::in_memory()?);
8009 let lease_provider = Arc::new(LocalLeaseProvider::new());
8010 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
8011 continuity_store: store.clone(),
8012 lease_provider: lease_provider.clone(),
8013 runtime_instance_id: "console-hidden-stale-duplicate-cleanup".to_string(),
8014 has_runtime_store: true,
8015 durability_policy: DurabilityPolicy::SyncWriteThrough,
8016 bridge: None,
8017 default_timeout: None,
8018 }));
8019 let identity = AgentIdentity::parse("review:singleton")?;
8020 let record = ContinuityRecord {
8021 identity: identity.clone(),
8022 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
8023 session_id: runtime
8024 .handle()
8025 .resolve_bridge_session_id_observation(&meerkat_mob::ids::MeerkatId::from(
8026 "rt:review:singleton:0",
8027 ))
8028 .await
8029 .unwrap_or_else(meerkat_core::types::SessionId::new),
8030 generation: ContinuityGeneration::new(0),
8031 checkpoint_version: CheckpointVersion::new(0),
8032 };
8033 let grants = lease_provider
8034 .acquire_leases(
8035 std::slice::from_ref(&identity),
8036 "console-hidden-stale-duplicate-cleanup",
8037 )
8038 .await?;
8039 let grant = match grants.get(&identity).cloned() {
8040 Some(LeaseAcquireResult::Acquired(grant)) => grant,
8041 other => return Err(format!("expected acquired lease, got {other:?}").into()),
8042 };
8043 store
8044 .upsert_continuity_record(&record, grant.fencing_token)
8045 .await?;
8046 identity_runtime
8047 .register(
8048 DurableAgentSpec {
8049 identity: identity.clone(),
8050 profile: ProfileName::from("worker"),
8051 addressability: AgentAddressability::Addressable,
8052 display_name: None,
8053 labels: BTreeMap::new(),
8054 context: None,
8055 additional_instructions: Vec::new(),
8056 initial_message: None,
8057 runtime_mode_override: None,
8058 },
8059 IdentityLifecycleState::Active,
8060 Some(record),
8061 Some(grant),
8062 )
8063 .await;
8064
8065 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
8066 &runtime,
8067 None,
8068 None,
8069 None,
8070 Some(ConsoleEventStore::new()),
8071 None,
8072 Some(identity_runtime),
8073 None,
8074 None,
8075 &HideOnlyMemberPolicy("rt:review:singleton:1"),
8076 rpc_request_with_params("mobkit/retire", json!({ "identity": "review:singleton" })),
8077 true,
8078 ))
8079 .await;
8080 assert_eq!(
8081 response["error"],
8082 Value::Null,
8083 "visible durable retire should succeed without touching hidden duplicate: {response:#?}"
8084 );
8085 assert!(
8086 runtime
8087 .handle()
8088 .get_member(&meerkat_mob::ids::MeerkatId::from("rt:review:singleton:1"))
8089 .await
8090 .is_some(),
8091 "post-mutation stale cleanup must not retire member-hidden projected duplicates"
8092 );
8093
8094 let _ = runtime.handle().stop().await;
8095 Ok(())
8096 }
8097
8098 #[tokio::test]
8099 async fn console_runtime_capabilities_advertise_identity_controls_when_identity_runtime_exists()
8100 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
8101 let (_temp_dir, runtime) =
8102 build_empty_console_test_runtime("console-identity-capabilities").await?;
8103 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
8104 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
8105 lease_provider: Arc::new(LocalLeaseProvider::new()),
8106 runtime_instance_id: "console-identity-capabilities".to_string(),
8107 has_runtime_store: true,
8108 durability_policy: DurabilityPolicy::SyncWriteThrough,
8109 bridge: None,
8110 default_timeout: None,
8111 }));
8112
8113 let response = Box::pin(handle_console_runtime_rpc(
8114 &runtime,
8115 None,
8116 None,
8117 None,
8118 None,
8119 None,
8120 Some(identity_runtime),
8121 None,
8122 None,
8123 rpc_request("mobkit/capabilities"),
8124 true,
8125 ))
8126 .await;
8127
8128 assert_eq!(response["error"], Value::Null, "{response:#?}");
8129 let methods = response["result"]["methods"]
8130 .as_array()
8131 .ok_or("capabilities methods should be an array")?;
8132 for method in [
8133 "mobkit/status_identity",
8134 "mobkit/inspect_identity",
8135 "mobkit/respawn",
8136 "mobkit/reset",
8137 "mobkit/delete_identity",
8138 ] {
8139 assert!(
8140 methods.iter().any(|candidate| candidate == method),
8141 "identity runtime capabilities should advertise {method}: {methods:#?}"
8142 );
8143 }
8144
8145 let _ = runtime.handle().stop().await;
8146 Ok(())
8147 }
8148
8149 #[tokio::test]
8150 async fn console_runtime_identity_reads_reject_stale_runtime_aliases()
8151 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
8152 let (_temp_dir, runtime) =
8153 build_empty_console_test_runtime("console-identity-stale-read-alias").await?;
8154 let mut labels = BTreeMap::new();
8155 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
8156 runtime
8157 .handle()
8158 .spawn_spec(
8159 SpawnMemberSpec::from_wire(
8160 "worker".to_string(),
8161 "rt:review:singleton:0".to_string(),
8162 Some("You are the stale Review Agent.".into()),
8163 None,
8164 None,
8165 )
8166 .with_labels(labels),
8167 )
8168 .await?;
8169
8170 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
8171 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
8172 lease_provider: Arc::new(LocalLeaseProvider::new()),
8173 runtime_instance_id: "console-identity-stale-read-alias".to_string(),
8174 has_runtime_store: true,
8175 durability_policy: DurabilityPolicy::SyncWriteThrough,
8176 bridge: None,
8177 default_timeout: None,
8178 }));
8179 let identity = AgentIdentity::parse("review:singleton")?;
8180 let record = ContinuityRecord {
8181 identity: identity.clone(),
8182 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:1")?,
8183 session_id: meerkat_core::types::SessionId::new(),
8184 generation: ContinuityGeneration::new(1),
8185 checkpoint_version: CheckpointVersion::new(0),
8186 };
8187 identity_runtime
8188 .register(
8189 DurableAgentSpec {
8190 identity: identity.clone(),
8191 profile: ProfileName::from("worker"),
8192 addressability: AgentAddressability::Addressable,
8193 display_name: None,
8194 labels: BTreeMap::new(),
8195 context: None,
8196 additional_instructions: Vec::new(),
8197 initial_message: None,
8198 runtime_mode_override: None,
8199 },
8200 IdentityLifecycleState::Active,
8201 Some(record),
8202 Some(LeaseGrant {
8203 identity,
8204 fencing_token: FencingToken::new(7),
8205 ttl: Duration::from_mins(1),
8206 }),
8207 )
8208 .await;
8209
8210 for requested_identity in ["rt:review:singleton:0", "review:singleton"] {
8211 for method in ["mobkit/status_identity", "mobkit/inspect_identity"] {
8212 let response = Box::pin(handle_console_runtime_rpc(
8213 &runtime,
8214 None,
8215 None,
8216 None,
8217 None,
8218 None,
8219 Some(identity_runtime.clone()),
8220 None,
8221 None,
8222 rpc_request_with_params(method, json!({ "identity": requested_identity })),
8223 true,
8224 ))
8225 .await;
8226 assert_ne!(
8227 response["error"],
8228 Value::Null,
8229 "{method} must reject stale alias for {requested_identity}"
8230 );
8231 let message = response["error"]["message"].as_str().unwrap_or_default();
8232 assert!(
8233 message.contains(
8234 "identity runtime binding for review:singleton points at rt:review:singleton:1"
8235 ),
8236 "unexpected stale-alias message for {method}/{requested_identity}: {message}"
8237 );
8238 assert_eq!(
8239 response["error"]["data"]["kind"],
8240 json!("stale_identity_runtime_binding")
8241 );
8242 assert_eq!(
8243 response["error"]["data"]["registered_runtime_member_id"],
8244 json!("rt:review:singleton:1")
8245 );
8246 assert_eq!(
8247 response["error"]["data"]["live_runtime_member_id"],
8248 json!("rt:review:singleton:0")
8249 );
8250 }
8251 }
8252
8253 let (_temp_dir_without_stale, runtime_without_stale) =
8254 build_empty_console_test_runtime("console-identity-no-live-stale-alias").await?;
8255
8256 for method in [
8257 "mobkit/status_identity",
8258 "mobkit/inspect_identity",
8259 "mobkit/retire",
8260 "mobkit/respawn",
8261 "mobkit/reset",
8262 ] {
8263 let response = Box::pin(handle_console_runtime_rpc(
8264 &runtime_without_stale,
8265 None,
8266 None,
8267 None,
8268 Some(ConsoleEventStore::new()),
8269 None,
8270 Some(identity_runtime.clone()),
8271 None,
8272 None,
8273 rpc_request_with_params(method, json!({ "identity": "rt:review:singleton:0" })),
8274 true,
8275 ))
8276 .await;
8277 assert_ne!(
8278 response["error"],
8279 Value::Null,
8280 "{method} must reject stale synthetic runtime alias"
8281 );
8282 assert!(
8283 response["error"]["message"]
8284 .as_str()
8285 .unwrap_or_default()
8286 .contains("identity not found: rt:review:singleton:0"),
8287 "unexpected no-live stale-alias response for {method}: {response:#?}"
8288 );
8289 }
8290 let _ = runtime_without_stale.handle().stop().await;
8291
8292 let _ = runtime.handle().stop().await;
8293 Ok(())
8294 }
8295
8296 #[tokio::test]
8297 async fn aggregator_live_snapshot_projects_identity_first_topology_peers()
8298 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
8299 let (_temp_dir, mob_runtime) =
8300 build_empty_console_test_runtime("identity-topology-snapshot-test").await?;
8301 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
8302 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
8303 lease_provider: Arc::new(LocalLeaseProvider::new()),
8304 runtime_instance_id: "console-topology-snapshot-test".to_string(),
8305 has_runtime_store: true,
8306 durability_policy: DurabilityPolicy::SyncWriteThrough,
8307 bridge: None,
8308 default_timeout: None,
8309 }));
8310
8311 for name in ["agent:alpha", "agent:beta"] {
8312 let identity = AgentIdentity::parse(name)?;
8313 let record = ContinuityRecord {
8314 identity: identity.clone(),
8315 agent_runtime_id: AgentRuntimeId::parse(&format!("rt:{name}:0"))?,
8316 session_id: meerkat_core::types::SessionId::new(),
8317 generation: ContinuityGeneration::new(0),
8318 checkpoint_version: CheckpointVersion::new(0),
8319 };
8320 identity_runtime
8321 .register(
8322 DurableAgentSpec {
8323 identity: identity.clone(),
8324 profile: ProfileName::from("default"),
8325 addressability: AgentAddressability::Addressable,
8326 display_name: None,
8327 labels: BTreeMap::new(),
8328 context: None,
8329 additional_instructions: Vec::new(),
8330 initial_message: None,
8331 runtime_mode_override: None,
8332 },
8333 IdentityLifecycleState::Active,
8334 Some(record),
8335 Some(LeaseGrant {
8336 identity,
8337 fencing_token: FencingToken::new(7),
8338 ttl: Duration::from_mins(1),
8339 }),
8340 )
8341 .await;
8342 }
8343 identity_runtime
8344 .set_desired_peer_edges(vec![ManagedPeerEdge::new(
8345 AgentIdentity::parse("agent:alpha")?,
8346 AgentIdentity::parse("agent:beta")?,
8347 )?])
8348 .await;
8349
8350 let aggregator = MobKitConsoleAggregator::in_memory();
8351 aggregator.register_runtime_handles_with_policy(
8352 "identity-first",
8353 "",
8354 mob_runtime.clone(),
8355 Some(identity_runtime),
8356 ConsoleEventStore::new(),
8357 Arc::new(AllowAllConsoleVisibilityPolicy),
8358 );
8359
8360 let snapshot = build_aggregator_live_snapshot(&aggregator, &[]).await?;
8361 let alpha = snapshot
8362 .members
8363 .iter()
8364 .find(|member| member.agent_identity == "agent:alpha")
8365 .ok_or("agent:alpha missing from live snapshot")?;
8366 assert_eq!(alpha.wired_to, vec!["agent:beta".to_string()]);
8367
8368 let _ = mob_runtime.handle().stop().await;
8369 Ok(())
8370 }
8371
8372 #[tokio::test]
8373 async fn identity_first_console_send_reserves_timeline_and_uses_identity_runtime()
8374 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
8375 let (_temp_dir, mob_runtime) =
8376 build_empty_console_test_runtime("identity-send-runtime-key-test").await?;
8377 let identity = AgentIdentity::parse("agent:console")?;
8378 let record = ContinuityRecord {
8379 identity: identity.clone(),
8380 agent_runtime_id: AgentRuntimeId::parse("rt:agent:console:0")?,
8381 session_id: meerkat_core::types::SessionId::new(),
8382 generation: ContinuityGeneration::new(0),
8383 checkpoint_version: CheckpointVersion::new(0),
8384 };
8385 let runtime = IdentityRuntime::new(IdentityRuntimeConfig {
8386 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
8387 lease_provider: Arc::new(LocalLeaseProvider::new()),
8388 runtime_instance_id: "console-test".to_string(),
8389 has_runtime_store: true,
8390 durability_policy: DurabilityPolicy::SyncWriteThrough,
8391 bridge: None,
8392 default_timeout: None,
8393 });
8394 runtime
8395 .register(
8396 DurableAgentSpec {
8397 identity: identity.clone(),
8398 profile: ProfileName::from("default"),
8399 addressability: AgentAddressability::Addressable,
8400 display_name: None,
8401 labels: BTreeMap::new(),
8402 context: None,
8403 additional_instructions: Vec::new(),
8404 initial_message: None,
8405 runtime_mode_override: None,
8406 },
8407 IdentityLifecycleState::Active,
8408 Some(record.clone()),
8409 Some(LeaseGrant {
8410 identity: identity.clone(),
8411 fencing_token: FencingToken::new(7),
8412 ttl: Duration::from_mins(1),
8413 }),
8414 )
8415 .await;
8416
8417 let aggregator = MobKitConsoleAggregator::in_memory();
8418 let events = ConsoleEventStore::new();
8419 let runtime = Arc::new(runtime);
8420 aggregator.register_runtime_handles_with_policy(
8421 "default",
8422 "",
8423 mob_runtime.clone(),
8424 Some(runtime.clone()),
8425 events.clone(),
8426 Arc::new(AllowAllConsoleVisibilityPolicy),
8427 );
8428 let accepted = console_send_identity_first(
8429 &aggregator,
8430 runtime.clone(),
8431 Some(&events),
8432 crate::console_aggregator::ConsoleSendRequest {
8433 identity: identity.as_str().to_string(),
8434 content: serde_json::to_value(meerkat_core::ContentInput::Text(
8435 "hello".to_string(),
8436 ))?,
8437 origin: "test".to_string(),
8438 idempotency_key: "idem-1".to_string(),
8439 handling_mode: None,
8440 },
8441 )
8442 .await?;
8443
8444 assert_eq!(accepted.identity, identity.as_str());
8445 assert_eq!(accepted.status, ConsoleFrameStatus::Accepted);
8446 assert_eq!(accepted.session_id, Some(record.session_id.to_string()));
8447
8448 let page = aggregator
8449 .query_timeline(ConsoleTimelineQuery {
8450 identity: Some(identity.as_str().to_string()),
8451 ..ConsoleTimelineQuery::default()
8452 })
8453 .await?;
8454 assert_eq!(page.frames.len(), 1);
8455 assert_eq!(page.frames[0].runtime_key, "default");
8456 assert_eq!(page.frames[0].status, ConsoleFrameStatus::Accepted);
8457 assert_eq!(
8458 page.frames[0].session_id,
8459 Some(record.session_id.to_string())
8460 );
8461 let _ = mob_runtime.handle().stop().await;
8462 Ok(())
8463 }
8464
8465 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
8466 async fn identity_first_console_send_falls_back_to_member_only_spawned_worker()
8467 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
8468 let (_temp_dir, mob_runtime) =
8469 build_empty_console_test_runtime("identity-send-member-only-test").await?;
8470 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
8471 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
8472 lease_provider: Arc::new(LocalLeaseProvider::new()),
8473 runtime_instance_id: "console-member-only-send-test".to_string(),
8474 has_runtime_store: true,
8475 durability_policy: DurabilityPolicy::SyncWriteThrough,
8476 bridge: None,
8477 default_timeout: None,
8478 }));
8479
8480 let aggregator = MobKitConsoleAggregator::in_memory();
8481 let events = ConsoleEventStore::new();
8482 aggregator.register_runtime_handles_with_policy(
8483 "identity-first",
8484 "",
8485 mob_runtime.clone(),
8486 Some(identity_runtime.clone()),
8487 events.clone(),
8488 Arc::new(AllowAllConsoleVisibilityPolicy),
8489 );
8490
8491 mob_runtime
8492 .handle()
8493 .spawn_spec(SpawnMemberSpec::from_wire(
8494 "worker".to_string(),
8495 "agent:member-only".to_string(),
8496 Some("You are a member-only spawned worker.".into()),
8497 None,
8498 None,
8499 ))
8500 .await?;
8501
8502 let accepted = console_send_with_identity_first_fallback(
8503 &aggregator,
8504 identity_runtime,
8505 Some(&events),
8506 crate::console_aggregator::ConsoleSendRequest {
8507 identity: "agent:member-only".to_string(),
8508 content: serde_json::to_value(meerkat_core::ContentInput::Text(
8509 "hello spawned worker".to_string(),
8510 ))?,
8511 origin: "test".to_string(),
8512 idempotency_key: "member-only-idem-1".to_string(),
8513 handling_mode: None,
8514 },
8515 )
8516 .await?;
8517
8518 assert_eq!(accepted.identity, "agent:member-only");
8519 assert!(accepted.session_id.is_some());
8520
8521 let page = aggregator
8522 .query_timeline(ConsoleTimelineQuery {
8523 identity: Some("agent:member-only".to_string()),
8524 ..ConsoleTimelineQuery::default()
8525 })
8526 .await?;
8527 assert!(
8528 page.frames.iter().any(|frame| frame.kind == "user_input"),
8529 "fallback send should persist a user input frame for the member-only worker: {page:#?}"
8530 );
8531
8532 let _ = mob_runtime.handle().stop().await;
8533 Ok(())
8534 }
8535
8536 #[tokio::test]
8537 async fn identity_first_console_send_returns_before_bridge_delivery_completes()
8538 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
8539 let identity = AgentIdentity::parse("agent:slow-console")?;
8540 let record = ContinuityRecord {
8541 identity: identity.clone(),
8542 agent_runtime_id: AgentRuntimeId::parse("rt:agent:slow-console:0")?,
8543 session_id: meerkat_core::types::SessionId::new(),
8544 generation: ContinuityGeneration::new(0),
8545 checkpoint_version: CheckpointVersion::new(0),
8546 };
8547 let deliver_calls = Arc::new(AtomicUsize::new(0));
8548 let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
8549 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
8550 lease_provider: Arc::new(LocalLeaseProvider::new()),
8551 runtime_instance_id: "console-slow-send-test".to_string(),
8552 has_runtime_store: true,
8553 durability_policy: DurabilityPolicy::SyncWriteThrough,
8554 bridge: Some(Arc::new(BlockingIdentityBridge {
8555 deliver_calls: deliver_calls.clone(),
8556 })),
8557 default_timeout: None,
8558 }));
8559 runtime
8560 .register(
8561 DurableAgentSpec {
8562 identity: identity.clone(),
8563 profile: ProfileName::from("default"),
8564 addressability: AgentAddressability::Addressable,
8565 display_name: None,
8566 labels: BTreeMap::new(),
8567 context: None,
8568 additional_instructions: Vec::new(),
8569 initial_message: None,
8570 runtime_mode_override: None,
8571 },
8572 IdentityLifecycleState::Active,
8573 Some(record.clone()),
8574 Some(LeaseGrant {
8575 identity: identity.clone(),
8576 fencing_token: FencingToken::new(7),
8577 ttl: Duration::from_mins(1),
8578 }),
8579 )
8580 .await;
8581
8582 let aggregator = MobKitConsoleAggregator::in_memory();
8583 let accepted = match tokio::time::timeout(
8584 Duration::from_millis(100),
8585 console_send_identity_first(
8586 &aggregator,
8587 runtime,
8588 None,
8589 crate::console_aggregator::ConsoleSendRequest {
8590 identity: identity.as_str().to_string(),
8591 content: serde_json::to_value(meerkat_core::ContentInput::Text(
8592 "hello slow bridge".to_string(),
8593 ))?,
8594 origin: "test".to_string(),
8595 idempotency_key: "idem-slow-bridge".to_string(),
8596 handling_mode: None,
8597 },
8598 ),
8599 )
8600 .await
8601 {
8602 Ok(Ok(accepted)) => accepted,
8603 Ok(Err(err)) => return Err(format!("send should be accepted: {err}").into()),
8604 Err(err) => {
8605 return Err(
8606 format!("console send should not wait for bridge delivery: {err}").into(),
8607 );
8608 }
8609 };
8610
8611 assert_eq!(accepted.status, ConsoleFrameStatus::Accepted);
8612 if tokio::time::timeout(Duration::from_millis(100), async {
8613 while deliver_calls.load(Ordering::SeqCst) == 0 {
8614 tokio::time::sleep(Duration::from_millis(5)).await;
8615 }
8616 })
8617 .await
8618 .is_err()
8619 {
8620 return Err("delivery should be spawned in the background".into());
8621 }
8622 Ok(())
8623 }
8624
8625 #[tokio::test]
8626 async fn identity_first_console_steer_waits_for_bridge_delivery()
8627 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
8628 let identity = AgentIdentity::parse("agent:slow-steer-console")?;
8629 let record = ContinuityRecord {
8630 identity: identity.clone(),
8631 agent_runtime_id: AgentRuntimeId::parse("rt:agent:slow-steer-console:0")?,
8632 session_id: meerkat_core::types::SessionId::new(),
8633 generation: ContinuityGeneration::new(0),
8634 checkpoint_version: CheckpointVersion::new(0),
8635 };
8636 let deliver_calls = Arc::new(AtomicUsize::new(0));
8637 let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
8638 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
8639 lease_provider: Arc::new(LocalLeaseProvider::new()),
8640 runtime_instance_id: "console-slow-steer-send-test".to_string(),
8641 has_runtime_store: true,
8642 durability_policy: DurabilityPolicy::SyncWriteThrough,
8643 bridge: Some(Arc::new(BlockingIdentityBridge {
8644 deliver_calls: deliver_calls.clone(),
8645 })),
8646 default_timeout: None,
8647 }));
8648 runtime
8649 .register(
8650 DurableAgentSpec {
8651 identity: identity.clone(),
8652 profile: ProfileName::from("default"),
8653 addressability: AgentAddressability::Addressable,
8654 display_name: None,
8655 labels: BTreeMap::new(),
8656 context: None,
8657 additional_instructions: Vec::new(),
8658 initial_message: None,
8659 runtime_mode_override: None,
8660 },
8661 IdentityLifecycleState::Active,
8662 Some(record),
8663 Some(LeaseGrant {
8664 identity: identity.clone(),
8665 fencing_token: FencingToken::new(7),
8666 ttl: Duration::from_mins(1),
8667 }),
8668 )
8669 .await;
8670
8671 let aggregator = MobKitConsoleAggregator::in_memory();
8672 let result = tokio::time::timeout(
8673 Duration::from_millis(100),
8674 console_send_identity_first(
8675 &aggregator,
8676 runtime,
8677 None,
8678 crate::console_aggregator::ConsoleSendRequest {
8679 identity: identity.as_str().to_string(),
8680 content: serde_json::to_value(meerkat_core::ContentInput::Text(
8681 "hello slow steer bridge".to_string(),
8682 ))?,
8683 origin: "test".to_string(),
8684 idempotency_key: "idem-slow-steer-bridge".to_string(),
8685 handling_mode: Some("steer".to_string()),
8686 },
8687 ),
8688 )
8689 .await;
8690
8691 if result.is_ok() {
8692 return Err("steer send must wait for bridge delivery admission".into());
8693 }
8694 assert_eq!(
8695 deliver_calls.load(Ordering::SeqCst),
8696 1,
8697 "steer delivery should have reached the bridge before the console response waits"
8698 );
8699 Ok(())
8700 }
8701
8702 #[tokio::test]
8703 async fn identity_first_console_send_forwards_handling_mode()
8704 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
8705 let identity = AgentIdentity::parse("agent:mode-console")?;
8706 let record = ContinuityRecord {
8707 identity: identity.clone(),
8708 agent_runtime_id: AgentRuntimeId::parse("rt:agent:mode-console:0")?,
8709 session_id: meerkat_core::types::SessionId::new(),
8710 generation: ContinuityGeneration::new(0),
8711 checkpoint_version: CheckpointVersion::new(0),
8712 };
8713 let handling_modes = Arc::new(Mutex::new(Vec::new()));
8714 let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
8715 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
8716 lease_provider: Arc::new(LocalLeaseProvider::new()),
8717 runtime_instance_id: "console-mode-send-test".to_string(),
8718 has_runtime_store: true,
8719 durability_policy: DurabilityPolicy::SyncWriteThrough,
8720 bridge: Some(Arc::new(RecordingIdentityBridge {
8721 session_id: record.session_id.clone(),
8722 handling_modes: handling_modes.clone(),
8723 })),
8724 default_timeout: None,
8725 }));
8726 runtime
8727 .register(
8728 DurableAgentSpec {
8729 identity: identity.clone(),
8730 profile: ProfileName::from("default"),
8731 addressability: AgentAddressability::Addressable,
8732 display_name: None,
8733 labels: BTreeMap::new(),
8734 context: None,
8735 additional_instructions: Vec::new(),
8736 initial_message: None,
8737 runtime_mode_override: None,
8738 },
8739 IdentityLifecycleState::Active,
8740 Some(record),
8741 Some(LeaseGrant {
8742 identity: identity.clone(),
8743 fencing_token: FencingToken::new(7),
8744 ttl: Duration::from_mins(1),
8745 }),
8746 )
8747 .await;
8748
8749 let aggregator = MobKitConsoleAggregator::in_memory();
8750 let accepted = console_send_identity_first(
8751 &aggregator,
8752 runtime,
8753 None,
8754 crate::console_aggregator::ConsoleSendRequest {
8755 identity: identity.as_str().to_string(),
8756 content: serde_json::to_value(meerkat_core::ContentInput::Text(
8757 "hello steer bridge".to_string(),
8758 ))?,
8759 origin: "test".to_string(),
8760 idempotency_key: "idem-steer-bridge".to_string(),
8761 handling_mode: Some("steer".to_string()),
8762 },
8763 )
8764 .await?;
8765
8766 if tokio::time::timeout(Duration::from_millis(100), async {
8767 loop {
8768 if handling_modes
8769 .lock()
8770 .map(|modes| modes.contains(&HandlingMode::Steer))
8771 .unwrap_or(false)
8772 {
8773 break;
8774 }
8775 tokio::time::sleep(Duration::from_millis(5)).await;
8776 }
8777 })
8778 .await
8779 .is_err()
8780 {
8781 return Err("identity-first console send should forward steer mode".into());
8782 }
8783
8784 let terminal_frame = tokio::time::timeout(Duration::from_millis(500), async {
8785 loop {
8786 let page = aggregator
8787 .query_timeline(ConsoleTimelineQuery {
8788 identity: Some(identity.as_str().to_string()),
8789 ..ConsoleTimelineQuery::default()
8790 })
8791 .await
8792 .map_err(|err| format!("query timeline: {err}"))?;
8793 if page.frames.iter().any(|frame| {
8794 frame.kind == "interaction_complete"
8795 && frame.interaction_id.as_deref() == Some(accepted.interaction_id.as_str())
8796 && frame.payload.get("reason").and_then(Value::as_str)
8797 == Some("steer_delivered")
8798 }) {
8799 return Ok::<(), String>(());
8800 }
8801 tokio::time::sleep(Duration::from_millis(5)).await;
8802 }
8803 })
8804 .await;
8805 match terminal_frame {
8806 Ok(Ok(())) => {}
8807 Ok(Err(err)) => return Err(err.into()),
8808 Err(_) => {
8809 return Err(
8810 "identity-first steer send should terminalize its console interaction".into(),
8811 );
8812 }
8813 }
8814 Ok(())
8815 }
8816
8817 #[test]
8818 fn multipart_body_limit_covers_configured_image_limit() {
8819 const _: () = assert!(MAX_MULTIPART_BODY_BYTES > MAX_MULTIPART_IMAGE_BYTES);
8820 const _: () = assert!(MAX_MULTIPART_BODY_BYTES > 2 * 1024 * 1024);
8821 }
8822
8823 #[tokio::test]
8836 async fn cold_cache_waiter_resumes_when_refresh_lock_drops()
8837 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
8838 use std::sync::atomic::Ordering;
8839 use tokio::time::Duration;
8840
8841 let model = ConsoleSnapshotReadModel::default();
8842 let guard = model
8843 .refresh_lock
8844 .clone()
8845 .try_lock_owned()
8846 .map_err(|_| "refresh_lock unexpectedly contended at test start")?;
8847
8848 let model_for_waiter = model.clone();
8849 let waiter = tokio::spawn(async move {
8850 if model_for_waiter
8853 .primed
8854 .load(std::sync::atomic::Ordering::Acquire)
8855 {
8856 return;
8857 }
8858 let _wait_guard = model_for_waiter.refresh_lock.clone().lock_owned().await;
8859 assert!(
8862 model_for_waiter
8863 .primed
8864 .load(std::sync::atomic::Ordering::Acquire),
8865 "waiter acquired lock but primed is still false"
8866 );
8867 });
8868
8869 tokio::time::sleep(Duration::from_millis(20)).await;
8871
8872 model.primed.store(true, Ordering::Release);
8875 drop(guard);
8876
8877 let result = tokio::time::timeout(Duration::from_secs(1), waiter).await;
8878 assert!(
8879 result.is_ok(),
8880 "waiter should resume once the refresh lock drops"
8881 );
8882 Ok(())
8883 }
8884
8885 #[tokio::test]
8890 async fn snapshot_skips_refresh_lock_when_already_primed()
8891 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
8892 use std::sync::atomic::Ordering;
8893 use tokio::time::Duration;
8894
8895 let model = ConsoleSnapshotReadModel::default();
8896 model.primed.store(true, Ordering::Release);
8897 let _guard = model
8899 .refresh_lock
8900 .clone()
8901 .try_lock_owned()
8902 .map_err(|_| "refresh_lock unexpectedly contended at test start")?;
8903
8904 let snap_fast_path = async {
8910 assert!(
8911 model.primed.load(Ordering::Acquire),
8912 "primed precondition for hot-cache path"
8913 );
8914 };
8915 let result = tokio::time::timeout(Duration::from_millis(100), snap_fast_path).await;
8916 assert!(result.is_ok(), "hot-cache snapshot path should not block");
8917 Ok(())
8918 }
8919
8920 #[tokio::test]
8921 async fn console_aggregator_reset_all_rpc_rejects_destructive_retire_all_semantics()
8922 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
8923 let (_temp_dir, runtime) =
8924 build_empty_console_test_runtime("console-reset-fresh-identity-cache").await?;
8925 let aggregator = MobKitConsoleAggregator::in_memory();
8926 aggregator.register_runtime_handles_with_policy(
8927 "runtime-reset",
8928 "reset",
8929 runtime.clone(),
8930 None,
8931 ConsoleEventStore::new(),
8932 Arc::new(AllowAllConsoleVisibilityPolicy),
8933 );
8934 let primed_empty = aggregator.list_identities().await?;
8935 assert!(
8936 primed_empty.is_empty(),
8937 "test precondition: identity cache should be primed empty before late spawn"
8938 );
8939
8940 runtime
8941 .handle()
8942 .spawn_spec(SpawnMemberSpec::from_wire(
8943 "worker".to_string(),
8944 "agent-reset".to_string(),
8945 Some("You are agent-reset.".into()),
8946 None,
8947 None,
8948 ))
8949 .await?;
8950
8951 let response = Box::pin(handle_console_aggregator_rpc(
8952 Some(aggregator),
8953 rpc_request("mobkit/reset_all"),
8954 true,
8955 ))
8956 .await;
8957
8958 assert_eq!(response["result"], Value::Null);
8959 assert_eq!(
8960 response["error"]["data"]["kind"],
8961 json!("unsupported_reset_all_surface")
8962 );
8963 assert!(
8964 runtime
8965 .handle()
8966 .get_member(&meerkat_mob::ids::MeerkatId::from("agent-reset"))
8967 .await
8968 .is_some(),
8969 "aggregator reset_all must not retire live members while reporting unsupported"
8970 );
8971 let _ = runtime.handle().stop().await;
8972 Ok(())
8973 }
8974
8975 #[test]
8976 fn timeline_stream_cursor_filter_uses_numeric_console_sequence() {
8977 assert!(cursor_is_after(
8978 &ConsoleCursor::from("console:10"),
8979 &ConsoleCursor::from("console:9")
8980 ));
8981 assert!(!cursor_is_after(
8982 &ConsoleCursor::from("console:9"),
8983 &ConsoleCursor::from("console:10")
8984 ));
8985 }
8986
8987 #[test]
8988 fn console_live_snapshot_dedupes_repeated_delegate_identities() {
8989 let mut members = vec![
8990 ConsoleMember {
8991 agent_identity: "incident-commander".to_string(),
8992 role: "commander".to_string(),
8993 state: "active".to_string(),
8994 model_capabilities: Default::default(),
8995 runtime_mode: None,
8996 session_id: None,
8997 wired_to: Vec::new(),
8998 labels: BTreeMap::new(),
8999 },
9000 ConsoleMember {
9001 agent_identity: "qa-child".to_string(),
9002 role: "delegate".to_string(),
9003 state: "active".to_string(),
9004 model_capabilities: Default::default(),
9005 runtime_mode: None,
9006 session_id: Some("first".to_string()),
9007 wired_to: vec!["qa-parent".to_string()],
9008 labels: BTreeMap::from([(
9009 "delegate_host_identity".to_string(),
9010 "qa-parent".to_string(),
9011 )]),
9012 },
9013 ConsoleMember {
9014 agent_identity: "qa-child".to_string(),
9015 role: "delegate".to_string(),
9016 state: "active".to_string(),
9017 model_capabilities: Default::default(),
9018 runtime_mode: None,
9019 session_id: Some("second".to_string()),
9020 wired_to: vec!["qa-parent".to_string()],
9021 labels: BTreeMap::from([(
9022 "delegate_host_identity".to_string(),
9023 "qa-parent".to_string(),
9024 )]),
9025 },
9026 ];
9027
9028 dedupe_console_members_by_identity(&mut members);
9029
9030 assert_eq!(
9031 members
9032 .iter()
9033 .map(|member| member.agent_identity.as_str())
9034 .collect::<Vec<_>>(),
9035 vec!["incident-commander", "qa-child"]
9036 );
9037 assert_eq!(members[1].session_id.as_deref(), Some("first"));
9038 }
9039
9040 #[test]
9041 fn console_visibility_policy_hides_implicit_delegate_members_from_snapshot() {
9042 let mut snapshot = ConsoleLiveSnapshot::new(
9043 Some("runtime".to_string()),
9044 true,
9045 vec!["incident-commander".to_string(), "qa-child".to_string()],
9046 vec![
9047 ConsoleAgentLiveSnapshot {
9048 agent_id: "incident-commander".to_string(),
9049 member_id: "incident-commander".to_string(),
9050 label: "Incident Commander".to_string(),
9051 kind: "meerkat".to_string(),
9052 identity: Some("incident-commander".to_string()),
9053 role: Some("commander".to_string()),
9054 state: Some("active".to_string()),
9055 session_id: None,
9056 model_capabilities: Default::default(),
9057 response_phase: None,
9058 watched: None,
9059 alert_level: None,
9060 degraded: None,
9061 degraded_reason: None,
9062 },
9063 ConsoleAgentLiveSnapshot {
9064 agent_id: "qa-child".to_string(),
9065 member_id: "qa-child".to_string(),
9066 label: "QA Child".to_string(),
9067 kind: "meerkat".to_string(),
9068 identity: Some("qa-child".to_string()),
9069 role: Some("delegate".to_string()),
9070 state: Some("active".to_string()),
9071 session_id: Some("delegate-session".to_string()),
9072 model_capabilities: Default::default(),
9073 response_phase: None,
9074 watched: None,
9075 alert_level: None,
9076 degraded: None,
9077 degraded_reason: None,
9078 },
9079 ],
9080 vec![
9081 ConsoleMember {
9082 agent_identity: "incident-commander".to_string(),
9083 role: "commander".to_string(),
9084 state: "active".to_string(),
9085 model_capabilities: Default::default(),
9086 runtime_mode: None,
9087 session_id: None,
9088 wired_to: Vec::new(),
9089 labels: BTreeMap::new(),
9090 },
9091 ConsoleMember {
9092 agent_identity: "qa-child".to_string(),
9093 role: "delegate".to_string(),
9094 state: "active".to_string(),
9095 model_capabilities: Default::default(),
9096 runtime_mode: None,
9097 session_id: Some("delegate-session".to_string()),
9098 wired_to: vec!["qa-parent".to_string()],
9099 labels: BTreeMap::from([(
9100 "source_mob_id".to_string(),
9101 "implicit-qa-mob".to_string(),
9102 )]),
9103 },
9104 ],
9105 true,
9106 );
9107
9108 apply_console_visibility_policy(
9109 &mut snapshot,
9110 &HideImplicitDelegateMembersConsoleVisibilityPolicy,
9111 );
9112
9113 assert_eq!(
9114 snapshot
9115 .members
9116 .iter()
9117 .map(|member| member.agent_identity.as_str())
9118 .collect::<Vec<_>>(),
9119 vec!["incident-commander"]
9120 );
9121 assert_eq!(
9122 snapshot
9123 .agents
9124 .iter()
9125 .map(|agent| agent.agent_id.as_str())
9126 .collect::<Vec<_>>(),
9127 vec!["incident-commander"]
9128 );
9129 assert_eq!(snapshot.loaded_modules, vec!["incident-commander"]);
9130 }
9131
9132 #[tokio::test]
9133 async fn live_snapshot_member_projection_uses_roster_profile_capabilities()
9134 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9135 let temp_dir = tempfile::tempdir()?;
9136 let session_path = temp_dir.path().join("sessions");
9137 std::fs::create_dir_all(&session_path)?;
9138 let factory = AgentFactory::new(&session_path).comms(true);
9139 let session_service = Arc::new(build_ephemeral_service(factory, Config::default(), 16));
9140 let definition = MobDefinition::from_toml(
9141 r#"
9142[mob]
9143id = "console-snapshot-test"
9144
9145[profiles.worker]
9146model = "gpt-5.5"
9147
9148[profiles.worker.tools]
9149comms = true
9150"#,
9151 )?;
9152 let expected = model_capabilities_for_role(&definition, "worker");
9153 let runtime = MobRuntime::bootstrap(
9154 MobBootstrapSpec::new(definition, MobStorage::in_memory(), session_service)
9155 .with_options(MobBootstrapOptions {
9156 allow_ephemeral_sessions: true,
9157 notify_orchestrator_on_resume: true,
9158 default_llm_client: Some(Arc::new(TestClient::default())),
9159 }),
9160 )
9161 .await?;
9162 runtime
9163 .handle()
9164 .spawn_spec(SpawnMemberSpec::from_wire(
9165 "worker".to_string(),
9166 "worker:one".to_string(),
9167 Some("You are worker one.".into()),
9168 None,
9169 None,
9170 ))
9171 .await?;
9172
9173 let empty_read_model = ConsoleSnapshotReadModelState::default();
9174 let (members, session_owner_by_id) =
9175 project_console_members_from_handle(&runtime.handle(), None, None, &empty_read_model)
9176 .await;
9177
9178 assert_eq!(members.len(), 1);
9179 assert_eq!(members[0].model_capabilities, expected);
9180 assert_eq!(members[0].session_id, None);
9181 assert!(session_owner_by_id.is_empty());
9182
9183 let refreshed_read_model = collect_console_snapshot_read_model(&runtime).await;
9184 let (members, session_owner_by_id) = project_console_members_from_handle(
9185 &runtime.handle(),
9186 None,
9187 None,
9188 &refreshed_read_model,
9189 )
9190 .await;
9191 assert_eq!(
9192 members[0].session_id.as_ref(),
9193 session_owner_by_id.keys().next()
9194 );
9195
9196 assert_eq!(
9202 refreshed_read_model.primary_members.len(),
9203 members.len(),
9204 "primary_members cache should hold the same members as live projection"
9205 );
9206 assert_eq!(
9207 refreshed_read_model.primary_members[0].agent_identity,
9208 members[0].agent_identity
9209 );
9210 assert_eq!(
9211 refreshed_read_model.primary_members[0].session_id,
9212 members[0].session_id
9213 );
9214 Ok(())
9215 }
9216
9217 #[tokio::test]
9218 async fn fresh_timeline_snapshot_reads_tail_without_full_log_replay()
9219 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9220 let aggregator = MobKitConsoleAggregator::in_memory();
9221 for idx in 0..250_000 {
9222 aggregator
9223 .store()
9224 .append_if_absent(NewConsoleFrame {
9225 id: None,
9226 dedupe_key: format!("event-{idx}"),
9227 timestamp_ms: idx,
9228 runtime_key: "runtime-a".to_string(),
9229 identity: "agent-a".to_string(),
9230 conversation_id: Some("agent-a".to_string()),
9231 session_id: None,
9232 kind: "text_delta".to_string(),
9233 status: ConsoleFrameStatus::Completed,
9234 payload: json!({ "delta": idx }),
9235 source: ConsoleFrameSource {
9236 kind: ConsoleFrameSourceKind::ConsoleEvent,
9237 source_cursor: None,
9238 },
9239 source_event_id: Some(format!("event-{idx}")),
9240 interaction_id: None,
9241 turn_id: None,
9242 run_id: None,
9243 parent_frame_id: None,
9244 caused_by_frame_id: None,
9245 })
9246 .await?;
9247 }
9248
9249 let (frames, cursor) = query_timeline_snapshot(
9250 &aggregator,
9251 ConsoleTimelineWindowQuery {
9252 identity: Some("agent-a".to_string()),
9253 after: None,
9254 limit: 200,
9255 ..ConsoleTimelineWindowQuery::default()
9256 },
9257 )
9258 .await?;
9259
9260 assert!(!frames.is_empty());
9261 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(250_000));
9262 assert_eq!(
9263 frames.last().and_then(|frame| frame.cursor.seq()),
9264 Some(250_000)
9265 );
9266 Ok(())
9267 }
9268
9269 #[tokio::test]
9270 async fn fresh_timeline_snapshot_keeps_sparse_identity_frames()
9271 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9272 let aggregator = MobKitConsoleAggregator::in_memory();
9273 aggregator
9274 .store()
9275 .append_if_absent(NewConsoleFrame {
9276 id: None,
9277 dedupe_key: "sparse-event".to_string(),
9278 timestamp_ms: 1,
9279 runtime_key: "runtime-a".to_string(),
9280 identity: "sparse-agent".to_string(),
9281 conversation_id: Some("sparse-agent".to_string()),
9282 session_id: None,
9283 kind: "text_complete".to_string(),
9284 status: ConsoleFrameStatus::Completed,
9285 payload: json!({ "text": "still visible" }),
9286 source: ConsoleFrameSource {
9287 kind: ConsoleFrameSourceKind::ConsoleEvent,
9288 source_cursor: None,
9289 },
9290 source_event_id: Some("sparse-event".to_string()),
9291 interaction_id: None,
9292 turn_id: None,
9293 run_id: None,
9294 parent_frame_id: None,
9295 caused_by_frame_id: None,
9296 })
9297 .await?;
9298 for idx in 0..25_000 {
9299 aggregator
9300 .store()
9301 .append_if_absent(NewConsoleFrame {
9302 id: None,
9303 dedupe_key: format!("other-event-{idx}"),
9304 timestamp_ms: idx + 2,
9305 runtime_key: "runtime-a".to_string(),
9306 identity: "busy-agent".to_string(),
9307 conversation_id: Some("busy-agent".to_string()),
9308 session_id: None,
9309 kind: "text_delta".to_string(),
9310 status: ConsoleFrameStatus::Completed,
9311 payload: json!({ "delta": idx }),
9312 source: ConsoleFrameSource {
9313 kind: ConsoleFrameSourceKind::ConsoleEvent,
9314 source_cursor: None,
9315 },
9316 source_event_id: Some(format!("other-event-{idx}")),
9317 interaction_id: None,
9318 turn_id: None,
9319 run_id: None,
9320 parent_frame_id: None,
9321 caused_by_frame_id: None,
9322 })
9323 .await?;
9324 }
9325
9326 let (frames, cursor) = query_timeline_snapshot(
9327 &aggregator,
9328 ConsoleTimelineWindowQuery {
9329 identity: Some("sparse-agent".to_string()),
9330 after: None,
9331 limit: 200,
9332 ..ConsoleTimelineWindowQuery::default()
9333 },
9334 )
9335 .await?;
9336
9337 assert_eq!(frames.len(), 1);
9338 assert_eq!(frames[0].identity, "sparse-agent");
9339 assert_eq!(frames[0].payload["text"], json!("still visible"));
9340 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(1));
9341 Ok(())
9342 }
9343
9344 #[tokio::test]
9345 async fn fresh_identity_snapshot_keeps_user_input_anchor_before_noisy_tail()
9346 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9347 let aggregator = MobKitConsoleAggregator::in_memory();
9348 aggregator
9349 .store()
9350 .append_if_absent(NewConsoleFrame {
9351 id: None,
9352 dedupe_key: "worker-kickoff".to_string(),
9353 timestamp_ms: 1,
9354 runtime_key: "runtime-a".to_string(),
9355 identity: "review-worker-a".to_string(),
9356 conversation_id: Some("review-worker-a".to_string()),
9357 session_id: None,
9358 kind: "user_input".to_string(),
9359 status: ConsoleFrameStatus::Delivered,
9360 payload: json!({
9361 "content": [
9362 {
9363 "type": "text",
9364 "text": "Console chat smoke: review this initiative"
9365 }
9366 ]
9367 }),
9368 source: ConsoleFrameSource {
9369 kind: ConsoleFrameSourceKind::Synthetic,
9370 source_cursor: None,
9371 },
9372 source_event_id: Some("worker-kickoff".to_string()),
9373 interaction_id: Some("kickoff-1".to_string()),
9374 turn_id: None,
9375 run_id: None,
9376 parent_frame_id: None,
9377 caused_by_frame_id: None,
9378 })
9379 .await?;
9380 for idx in 0..1_500 {
9381 aggregator
9382 .store()
9383 .append_if_absent(NewConsoleFrame {
9384 id: None,
9385 dedupe_key: format!("worker-delta-{idx}"),
9386 timestamp_ms: idx + 2,
9387 runtime_key: "runtime-a".to_string(),
9388 identity: "review-worker-a".to_string(),
9389 conversation_id: Some("review-worker-a".to_string()),
9390 session_id: None,
9391 kind: "reasoning_delta".to_string(),
9392 status: ConsoleFrameStatus::Delivered,
9393 payload: json!({ "delta": idx }),
9394 source: ConsoleFrameSource {
9395 kind: ConsoleFrameSourceKind::ConsoleEvent,
9396 source_cursor: None,
9397 },
9398 source_event_id: Some(format!("worker-delta-{idx}")),
9399 interaction_id: Some("kickoff-1".to_string()),
9400 turn_id: None,
9401 run_id: None,
9402 parent_frame_id: None,
9403 caused_by_frame_id: None,
9404 })
9405 .await?;
9406 }
9407
9408 let (frames, cursor) = query_timeline_snapshot(
9409 &aggregator,
9410 ConsoleTimelineWindowQuery {
9411 identity: Some("review-worker-a".to_string()),
9412 after: None,
9413 limit: 200,
9414 ..ConsoleTimelineWindowQuery::default()
9415 },
9416 )
9417 .await?;
9418
9419 assert!(
9420 frames.iter().any(|frame| {
9421 frame.kind == "user_input"
9422 && frame.payload.to_string().contains("Console chat smoke")
9423 }),
9424 "identity chat snapshot must keep the worker kickoff prompt before a noisy tail: {frames:#?}",
9425 );
9426 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(1_501));
9427 assert_eq!(
9428 frames.last().and_then(|frame| frame.cursor.seq()),
9429 Some(1_501)
9430 );
9431 Ok(())
9432 }
9433
9434 #[tokio::test]
9435 async fn timeline_snapshot_drains_since_backlog_across_store_pages()
9436 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9437 let aggregator = MobKitConsoleAggregator::in_memory();
9438 for idx in 0..2_500 {
9439 aggregator
9440 .store()
9441 .append_if_absent(NewConsoleFrame {
9442 id: None,
9443 dedupe_key: format!("clamp-event-{idx}"),
9444 timestamp_ms: idx,
9445 runtime_key: "runtime-a".to_string(),
9446 identity: "agent-a".to_string(),
9447 conversation_id: Some("agent-a".to_string()),
9448 session_id: None,
9449 kind: "text_delta".to_string(),
9450 status: ConsoleFrameStatus::Completed,
9451 payload: json!({ "delta": idx }),
9452 source: ConsoleFrameSource {
9453 kind: ConsoleFrameSourceKind::ConsoleEvent,
9454 source_cursor: None,
9455 },
9456 source_event_id: Some(format!("clamp-event-{idx}")),
9457 interaction_id: None,
9458 turn_id: None,
9459 run_id: None,
9460 parent_frame_id: None,
9461 caused_by_frame_id: None,
9462 })
9463 .await?;
9464 }
9465
9466 let (frames, cursor) = query_timeline_snapshot(
9467 &aggregator,
9468 ConsoleTimelineWindowQuery {
9469 identity: Some("agent-a".to_string()),
9470 after: Some(ConsoleCursor::from("console:100")),
9471 limit: 5_000,
9472 ..ConsoleTimelineWindowQuery::default()
9473 },
9474 )
9475 .await?;
9476
9477 assert_eq!(frames.len(), 2_400);
9478 assert_eq!(
9479 frames.first().and_then(|frame| frame.cursor.seq()),
9480 Some(101)
9481 );
9482 assert_eq!(
9483 frames.last().and_then(|frame| frame.cursor.seq()),
9484 Some(2_500)
9485 );
9486 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(2_500));
9487 Ok(())
9488 }
9489
9490 #[tokio::test]
9491 async fn timeline_snapshot_drains_since_backlog_beyond_old_page_budget()
9492 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9493 let aggregator = MobKitConsoleAggregator::in_memory();
9494 for idx in 1..=150 {
9495 aggregator
9496 .store()
9497 .append_if_absent(NewConsoleFrame {
9498 id: None,
9499 dedupe_key: format!("deep-backlog-event-{idx}"),
9500 timestamp_ms: idx,
9501 runtime_key: "runtime-a".to_string(),
9502 identity: "agent-a".to_string(),
9503 conversation_id: Some("agent-a".to_string()),
9504 session_id: None,
9505 kind: "text_delta".to_string(),
9506 status: ConsoleFrameStatus::Completed,
9507 payload: json!({ "delta": idx }),
9508 source: ConsoleFrameSource {
9509 kind: ConsoleFrameSourceKind::ConsoleEvent,
9510 source_cursor: None,
9511 },
9512 source_event_id: Some(format!("deep-backlog-event-{idx}")),
9513 interaction_id: None,
9514 turn_id: None,
9515 run_id: None,
9516 parent_frame_id: None,
9517 caused_by_frame_id: None,
9518 })
9519 .await?;
9520 }
9521
9522 let (frames, cursor) = query_timeline_snapshot(
9523 &aggregator,
9524 ConsoleTimelineWindowQuery {
9525 identity: Some("agent-a".to_string()),
9526 after: Some(ConsoleCursor::from_seq(1)),
9527 limit: 1,
9528 ..ConsoleTimelineWindowQuery::default()
9529 },
9530 )
9531 .await?;
9532
9533 assert_eq!(frames.len(), 149);
9534 assert_eq!(frames.first().and_then(|frame| frame.cursor.seq()), Some(2));
9535 assert_eq!(
9536 frames.last().and_then(|frame| frame.cursor.seq()),
9537 Some(150)
9538 );
9539 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(150));
9540 Ok(())
9541 }
9542
9543 #[tokio::test]
9544 async fn timeline_snapshot_rejects_after_cursor_beyond_store_frontier()
9545 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9546 let aggregator = MobKitConsoleAggregator::in_memory();
9547 aggregator
9548 .store()
9549 .append_if_absent(NewConsoleFrame {
9550 id: None,
9551 dedupe_key: "stale-frontier-event".to_string(),
9552 timestamp_ms: 1,
9553 runtime_key: "runtime-a".to_string(),
9554 identity: "agent-a".to_string(),
9555 conversation_id: Some("agent-a".to_string()),
9556 session_id: None,
9557 kind: "text_delta".to_string(),
9558 status: ConsoleFrameStatus::Completed,
9559 payload: json!({ "delta": 1 }),
9560 source: ConsoleFrameSource {
9561 kind: ConsoleFrameSourceKind::ConsoleEvent,
9562 source_cursor: None,
9563 },
9564 source_event_id: Some("stale-frontier-event".to_string()),
9565 interaction_id: None,
9566 turn_id: None,
9567 run_id: None,
9568 parent_frame_id: None,
9569 caused_by_frame_id: None,
9570 })
9571 .await?;
9572
9573 let err = match query_timeline_snapshot(
9574 &aggregator,
9575 ConsoleTimelineWindowQuery {
9576 after: Some(ConsoleCursor::from("console:99")),
9577 limit: 200,
9578 ..ConsoleTimelineWindowQuery::default()
9579 },
9580 )
9581 .await
9582 {
9583 Ok(_) => {
9584 return Err(
9585 std::io::Error::other("future cursor must be replay-unavailable").into(),
9586 );
9587 }
9588 Err(err) => err,
9589 };
9590
9591 assert!(
9592 err.to_string()
9593 .contains("beyond the current store frontier"),
9594 "unexpected error: {err}"
9595 );
9596 Ok(())
9597 }
9598
9599 #[tokio::test]
9600 async fn timeline_snapshot_rejects_after_cursor_beyond_empty_store_frontier()
9601 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9602 let aggregator = MobKitConsoleAggregator::in_memory();
9603
9604 let err = match query_timeline_snapshot(
9605 &aggregator,
9606 ConsoleTimelineWindowQuery {
9607 after: Some(ConsoleCursor::from("console:99")),
9608 limit: 200,
9609 ..ConsoleTimelineWindowQuery::default()
9610 },
9611 )
9612 .await
9613 {
9614 Ok(_) => {
9615 return Err(std::io::Error::other("empty store future cursor must fail").into());
9616 }
9617 Err(err) => err,
9618 };
9619
9620 assert!(
9621 err.to_string()
9622 .contains("beyond the current store frontier"),
9623 "unexpected error: {err}"
9624 );
9625 Ok(())
9626 }
9627
9628 #[test]
9629 fn timeline_query_prefers_last_event_id_over_url_after_cursor() {
9630 let query = timeline_query_from_http(
9631 ConsoleTimelineHttpQuery {
9632 identity: None,
9633 conversation_id: None,
9634 after: Some("console:100".to_string()),
9635 before: None,
9636 mode: None,
9637 limit: None,
9638 },
9639 Some("console:150".to_string()),
9640 );
9641
9642 assert_eq!(query.after.as_ref().and_then(ConsoleCursor::seq), Some(150));
9643 }
9644
9645 #[test]
9646 fn console_timeline_replay_unavailable_rpc_uses_dedicated_error_code() {
9647 let response = console_timeline_replay_unavailable_response(
9648 json!("rid"),
9649 std::io::Error::other("timeline replay cursor is beyond the current store frontier")
9650 .into(),
9651 Some(&ConsoleCursor::from_seq(100)),
9652 Some(ConsoleCursor::from_seq(42)),
9653 );
9654
9655 assert_eq!(response["error"]["code"], json!(-32013));
9656 assert_eq!(
9657 response["error"]["data"],
9658 json!({
9659 "error": "replay_unavailable",
9660 "stream": "timeline",
9661 "requested_cursor": "console:100",
9662 "latest_cursor": "console:42",
9663 })
9664 );
9665 }
9666
9667 #[tokio::test]
9668 async fn multipart_blob_upload_stores_one_file() -> Result<(), Box<dyn std::error::Error>> {
9669 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
9670 let mut files = BTreeMap::new();
9671 files.insert(
9672 "upload-1".to_string(),
9673 MultipartImageUpload {
9674 media_type: "image/png".to_string(),
9675 bytes: Bytes::from_static(b"png-data"),
9676 },
9677 );
9678 let result = externalize_single_image_upload(
9679 &json!({
9680 "upload": {
9681 "type": "image_upload",
9682 "upload_id": "upload-1",
9683 "media_type": "image/png"
9684 }
9685 }),
9686 files,
9687 store.clone(),
9688 )
9689 .await
9690 .map_err(std::io::Error::other)?;
9691
9692 assert_eq!(result["media_type"], json!("image/png"));
9693 assert_eq!(result["size"], json!(8));
9694 let Some(blob_id) = result["blob_id"].as_str() else {
9695 return Err(std::io::Error::other("blob id").into());
9696 };
9697 let payload = store
9698 .get_bytes(&meerkat_core::BlobId::from(blob_id))
9699 .await?;
9700 assert_eq!(payload.data.as_ref(), b"png-data");
9701 Ok(())
9702 }
9703
9704 #[tokio::test]
9705 async fn multipart_blob_upload_accepts_part_name_alias()
9706 -> Result<(), Box<dyn std::error::Error>> {
9707 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
9708 let mut files = BTreeMap::new();
9709 files.insert(
9710 "image-field".to_string(),
9711 MultipartImageUpload {
9712 media_type: "image/png".to_string(),
9713 bytes: Bytes::from_static(b"png-data"),
9714 },
9715 );
9716 let result = externalize_single_image_upload(
9717 &json!({
9718 "upload": {
9719 "type": "image_upload",
9720 "part_name": "image-field",
9721 "media_type": "image/png"
9722 }
9723 }),
9724 files,
9725 store,
9726 )
9727 .await
9728 .map_err(std::io::Error::other)?;
9729
9730 assert_eq!(result["media_type"], json!("image/png"));
9731 assert!(
9732 result["blob_id"]
9733 .as_str()
9734 .is_some_and(|value| value.starts_with("sha256:"))
9735 );
9736 Ok(())
9737 }
9738
9739 #[tokio::test]
9740 async fn multipart_blob_upload_rejects_media_mismatch() -> Result<(), Box<dyn std::error::Error>>
9741 {
9742 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
9743 let mut files = BTreeMap::new();
9744 files.insert(
9745 "upload-1".to_string(),
9746 MultipartImageUpload {
9747 media_type: "image/jpeg".to_string(),
9748 bytes: Bytes::from_static(b"jpeg-data"),
9749 },
9750 );
9751 let err = match externalize_single_image_upload(
9752 &json!({
9753 "upload": {
9754 "type": "image_upload",
9755 "upload_id": "upload-1",
9756 "media_type": "image/png"
9757 }
9758 }),
9759 files,
9760 store,
9761 )
9762 .await
9763 {
9764 Ok(_) => return Err(std::io::Error::other("media mismatch").into()),
9765 Err(err) => err,
9766 };
9767 assert!(
9768 err.contains("media type mismatch"),
9769 "unexpected error: {err}"
9770 );
9771 Ok(())
9772 }
9773
9774 #[tokio::test]
9775 async fn multipart_blob_upload_rejects_extra_file() -> Result<(), Box<dyn std::error::Error>> {
9776 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
9777 let mut files = BTreeMap::new();
9778 for id in ["upload-1", "upload-2"] {
9779 files.insert(
9780 id.to_string(),
9781 MultipartImageUpload {
9782 media_type: "image/png".to_string(),
9783 bytes: Bytes::from_static(b"png"),
9784 },
9785 );
9786 }
9787 let err = match externalize_single_image_upload(
9788 &json!({
9789 "upload": {
9790 "type": "image_upload",
9791 "upload_id": "upload-1",
9792 "media_type": "image/png"
9793 }
9794 }),
9795 files,
9796 store,
9797 )
9798 .await
9799 {
9800 Ok(_) => return Err(std::io::Error::other("one file only").into()),
9801 Err(err) => err,
9802 };
9803 assert!(
9804 err.contains("exactly one file part"),
9805 "unexpected error: {err}"
9806 );
9807 Ok(())
9808 }
9809
9810 #[tokio::test]
9811 async fn multipart_send_replaces_placeholders_and_removes_shadow_message()
9812 -> Result<(), Box<dyn std::error::Error>> {
9813 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
9814 let mut files = BTreeMap::new();
9815 files.insert(
9816 "upload-1".to_string(),
9817 MultipartImageUpload {
9818 media_type: "image/webp".to_string(),
9819 bytes: Bytes::from_static(b"webp-data"),
9820 },
9821 );
9822 let mut params = json!({
9823 "member_id": "artist",
9824 "message": "stale shadow text",
9825 "content": [
9826 { "type": "text", "text": "describe" },
9827 {
9828 "type": "image_upload",
9829 "upload_id": "upload-1",
9830 "media_type": "image/webp"
9831 }
9832 ]
9833 });
9834 externalize_image_upload_placeholders(&mut params, files, store)
9835 .await
9836 .map_err(std::io::Error::other)?;
9837
9838 assert!(params.get("message").is_none());
9839 assert_eq!(params["content"][1]["type"], json!("image"));
9840 assert_eq!(params["content"][1]["source"], json!("blob"));
9841 assert_eq!(params["content"][1]["media_type"], json!("image/webp"));
9842 assert!(
9843 params["content"][1]["blob_id"]
9844 .as_str()
9845 .is_some_and(|value| value.starts_with("sha256:"))
9846 );
9847 Ok(())
9848 }
9849
9850 #[tokio::test]
9851 async fn multipart_send_accepts_part_name_placeholder() -> Result<(), Box<dyn std::error::Error>>
9852 {
9853 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
9854 let mut files = BTreeMap::new();
9855 files.insert(
9856 "image-field".to_string(),
9857 MultipartImageUpload {
9858 media_type: "image/png".to_string(),
9859 bytes: Bytes::from_static(b"png-data"),
9860 },
9861 );
9862 let mut params = json!({
9863 "member_id": "analyst",
9864 "content": [
9865 { "type": "text", "text": "describe" },
9866 {
9867 "type": "image_upload",
9868 "part_name": "image-field",
9869 "media_type": "image/png"
9870 }
9871 ]
9872 });
9873
9874 externalize_image_upload_placeholders(&mut params, files, store)
9875 .await
9876 .map_err(std::io::Error::other)?;
9877
9878 assert_eq!(params["content"][1]["type"], json!("image"));
9879 assert_eq!(params["content"][1]["source"], json!("blob"));
9880 assert_eq!(params["content"][1]["media_type"], json!("image/png"));
9881 Ok(())
9882 }
9883
9884 #[tokio::test]
9885 async fn multipart_send_rejects_placeholder_without_file()
9886 -> Result<(), Box<dyn std::error::Error>> {
9887 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
9888 let mut params = json!({
9889 "content": [{
9890 "type": "image_upload",
9891 "upload_id": "missing",
9892 "media_type": "image/png"
9893 }]
9894 });
9895 let err = match externalize_image_upload_placeholders(&mut params, BTreeMap::new(), store)
9896 .await
9897 {
9898 Ok(()) => return Err(std::io::Error::other("missing file").into()),
9899 Err(err) => err,
9900 };
9901 assert!(err.contains("missing file part"), "unexpected error: {err}");
9902 Ok(())
9903 }
9904
9905 #[test]
9906 fn generated_runtime_ids_do_not_match_sibling_colon_identities() {
9907 assert!(!member_id_matches_durable_identity(
9908 "rt:review:singleton:0",
9909 "review:singleton"
9910 ));
9911 assert!(!member_id_matches_durable_identity(
9912 "review:singleton:gen1",
9913 "review:singleton"
9914 ));
9915 assert!(!member_id_matches_durable_identity(
9916 "review:singleton:1",
9917 "review:singleton"
9918 ));
9919 assert!(!member_id_matches_durable_identity(
9920 "rt:review:singleton:qa:0",
9921 "review:singleton"
9922 ));
9923 assert!(!member_id_matches_durable_identity(
9924 "review:singleton:qa",
9925 "review:singleton"
9926 ));
9927 }
9928}