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::StreamExt;
12use futures::future::join_all;
13use meerkat_contracts::WireRuntimeBinding;
14use meerkat_core::ContentInput;
15use meerkat_core::comms::TrustedPeerDescriptor;
16use meerkat_core::event::agent_event_type;
17use meerkat_mob::MobState;
18use meerkat_mob::ids::{AgentIdentity, MobId};
19use meerkat_mob::launch::MemberLaunchMode;
20use meerkat_mob::runtime::reconcile::MemberFilter;
21use meerkat_mob::{
22 MobBackendKind, MobHandle, MobRuntimeMode, PeerTarget, ProfileName, SpawnMemberSpec,
23};
24
25use crate::mob_handle_runtime::{
26 is_recoverable_lifecycle_cleanup_error, member_entry_to_json,
27 model_capabilities_for_member_entry, model_capabilities_for_role, resolved_tools_for_member,
28 resolved_tools_for_session, topology_restore_failed_peer_ids, topology_restore_warning_json,
29};
30use serde_json::{Value, json};
31use std::collections::{BTreeMap, BTreeSet};
32use std::convert::Infallible;
33use std::sync::Arc;
34use std::time::{Duration, Instant};
35
36use crate::access::{
37 ACCESS_ACTIONS, ACTION_AGENT_MEMORY_DELETE, ACTION_AGENT_MEMORY_READ,
38 ACTION_AGENT_MEMORY_WRITE, ACTION_AGENT_RESET, ACTION_AGENT_RESPAWN, ACTION_AGENT_RETIRE,
39 ACTION_AGENT_SEND, ACTION_AGENT_SPAWN, ACTION_AGENT_VIEW, ACTION_GATING_DECIDE,
40 ACTION_GATING_VIEW, ACTION_MEMORY_QUARANTINE_REVIEW, ACTION_MOB_MEMORY_READ,
41 ACTION_MOB_OBSERVE, ACTION_OPERATOR_MEMORY_READ, ACTION_RUNTIME_ADMIN, AccessController,
42 AccessGroup, AccessResource, AccessRule, AccessView, AgentResourceAttributes,
43};
44use crate::blob_store::{BinaryBlobPayload, BinaryBlobStore, is_valid_blob_id_value};
45use crate::console_aggregator::{
46 ConsoleCursor, ConsoleFrame, ConsoleIdentityRecord, ConsoleLogError, ConsoleLogResult,
47 ConsoleLogStore, ConsoleReplayUnavailable, ConsoleSendError, ConsoleSendRequest,
48 ConsoleTimelineEvent, ConsoleTimelineMode, ConsoleTimelineQuery, ConsoleTimelineWindowPage,
49 ConsoleTimelineWindowQuery, ConsoleVisibility, ConsoleVisibilityPolicy,
50 HideImplicitDelegateMembersConsoleVisibilityPolicy, MobKitConsoleAggregator,
51};
52use crate::contact_directory::ContactDirectory;
53use crate::http_sse::{DEFAULT_KEEP_ALIVE_INTERVAL, KEEP_ALIVE_TEXT};
54use crate::mob_handle_runtime::{
55 MEMBER_STATE_ACTIVE, MEMBER_STATE_RETIRING, MobRuntime, member_status_state_string,
56};
57use crate::rpc::memory_methods::{
58 parse_agent_memory_forget_params, parse_agent_memory_manifest_params,
59 parse_agent_memory_recall_params, parse_agent_memory_remember_params,
60 parse_agent_memory_update_params,
61};
62use crate::rpc::{JSONRPC_VERSION, JsonRpcError, JsonRpcRequest, JsonRpcResponse};
63use crate::runtime::MobkitRuntimeHandle;
64use crate::runtime::{
65 ConsoleAgentLiveSnapshot, ConsoleLiveSnapshot, ConsoleMember, ConsoleModelCapabilities,
66 ConsoleRestJsonRequest, DeliveryHistoryRequest, GatingDecideRequest, GatingDecision,
67 RuntimeDecisionState, extract_bearer_token_from_header,
68 handle_console_rest_json_route_with_snapshot_access_and_memory,
69 resolve_authorized_console_auth_from_token,
70};
71use crate::runtime::{MetadataScope, RuntimeMetadataTable, labels_to_json_value};
72use crate::types::{EventEnvelope, UnifiedEvent};
73use crate::unified_runtime::console_events::ConsoleEventStore;
74use crate::unified_runtime::mob_events::MobEventsStore;
75use crate::unified_runtime::{EventLogStore, EventQuery};
76
77#[derive(Clone)]
78pub struct ConsoleJsonState {
79 pub decisions: RuntimeDecisionState,
80 pub runtime: Option<MobRuntime>,
81 pub module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
82 pub contact_directory: Option<ContactDirectory>,
83 pub event_log: Option<std::sync::Arc<dyn EventLogStore>>,
84 pub gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
88 pub(crate) identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
89 pub(crate) console_events: Option<ConsoleEventStore>,
90 pub(crate) console_aggregator: Option<MobKitConsoleAggregator>,
91 pub(crate) mob_events: Option<MobEventsStore>,
92 pub(crate) metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
93 pub(crate) visibility_policy: Arc<dyn ConsoleVisibilityPolicy>,
94 pub(crate) snapshot_read_model: ConsoleSnapshotReadModel,
95 pub(crate) access: Option<AccessController>,
98 pub(crate) memory_panel: Option<crate::memory::sqlite_store::SqliteAgentMemoryStore>,
102}
103
104#[derive(Debug, Clone)]
105struct ConsoleHttpAuthContext {
106 principal: Option<String>,
107 access_view: Option<AccessView>,
109}
110
111#[derive(Clone, Default)]
112pub(crate) struct ConsoleSnapshotReadModel {
113 inner: Arc<tokio::sync::RwLock<ConsoleSnapshotReadModelState>>,
114 refresh_lock: Arc<tokio::sync::Mutex<()>>,
120 primed: Arc<std::sync::atomic::AtomicBool>,
124}
125
126#[derive(Clone, Default)]
127struct ConsoleSnapshotReadModelState {
128 running: Option<bool>,
129 session_id_by_identity: BTreeMap<String, String>,
130 session_owner_by_id: BTreeMap<String, String>,
131 primary_members: Vec<ConsoleMember>,
136 delegate_member_groups: Vec<Vec<ConsoleMember>>,
141}
142
143impl ConsoleSnapshotReadModel {
144 async fn snapshot(&self, runtime: &MobRuntime) -> ConsoleSnapshotReadModelState {
151 if !self.primed.load(std::sync::atomic::Ordering::Acquire) {
152 Box::pin(self.prime_now(runtime)).await;
153 }
154 self.inner.read().await.clone()
155 }
156
157 async fn prime_now(&self, runtime: &MobRuntime) {
177 if self.primed.load(std::sync::atomic::Ordering::Acquire) {
178 return;
179 }
180 let _guard = self.refresh_lock.clone().lock_owned().await;
181 if self.primed.load(std::sync::atomic::Ordering::Acquire) {
182 return;
183 }
184 let refreshed = Box::pin(collect_console_snapshot_read_model(runtime)).await;
185 *self.inner.write().await = refreshed;
186 self.primed
187 .store(true, std::sync::atomic::Ordering::Release);
188 }
192
193 fn refresh_soon(&self, runtime: MobRuntime) {
200 let Ok(runtime_handle) = tokio::runtime::Handle::try_current() else {
201 return;
202 };
203 let Ok(guard) = self.refresh_lock.clone().try_lock_owned() else {
204 return;
205 };
206 let inner = Arc::clone(&self.inner);
207 let primed = Arc::clone(&self.primed);
208 runtime_handle.spawn(async move {
209 let _guard = guard;
210 let refreshed = Box::pin(collect_console_snapshot_read_model(&runtime)).await;
211 *inner.write().await = refreshed;
212 primed.store(true, std::sync::atomic::Ordering::Release);
213 });
217 }
218}
219
220const CONSOLE_FRONTEND_INDEX_HTML: &str = include_str!("../console-dist/index.html");
221const CONSOLE_FRONTEND_APP_JS: &str = include_str!("../console-dist/console-app.js");
222const CONSOLE_FRONTEND_APP_CSS: &str = include_str!("../console-dist/console-app.css");
223const MAX_MULTIPART_IMAGE_BYTES: usize = 25 * 1024 * 1024;
224const MAX_MULTIPART_IMAGES: usize = 4;
225const MAX_MULTIPART_BODY_BYTES: usize =
226 (MAX_MULTIPART_IMAGE_BYTES * MAX_MULTIPART_IMAGES) + 1024 * 1024;
227
228pub fn console_json_router(decisions: RuntimeDecisionState) -> Router {
229 console_json_router_with_state(ConsoleJsonState {
230 decisions,
231 runtime: None,
232 module_runtime: None,
233 contact_directory: None,
234 event_log: None,
235 gateway_peer_keys: None,
236 identity_runtime: None,
237 console_events: None,
238 console_aggregator: None,
239 mob_events: None,
240 metadata_table: None,
241 visibility_policy: Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
242 snapshot_read_model: ConsoleSnapshotReadModel::default(),
243 access: None,
244 memory_panel: None,
245 })
246}
247
248pub fn console_json_router_with_aggregator(
249 decisions: RuntimeDecisionState,
250 console_aggregator: MobKitConsoleAggregator,
251) -> Router {
252 console_json_router_with_aggregator_and_access(decisions, console_aggregator, None)
253}
254
255pub fn console_json_router_with_aggregator_and_access(
256 decisions: RuntimeDecisionState,
257 console_aggregator: MobKitConsoleAggregator,
258 access: Option<AccessController>,
259) -> Router {
260 console_json_router_with_state(ConsoleJsonState {
261 decisions,
262 runtime: None,
263 module_runtime: None,
264 contact_directory: None,
265 event_log: None,
266 gateway_peer_keys: None,
267 identity_runtime: None,
268 console_events: None,
269 console_aggregator: Some(console_aggregator),
270 mob_events: None,
271 metadata_table: None,
272 visibility_policy: Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
273 snapshot_read_model: ConsoleSnapshotReadModel::default(),
274 access,
275 memory_panel: None,
276 })
277}
278
279pub fn console_json_router_with_runtime(
280 decisions: RuntimeDecisionState,
281 runtime: MobRuntime,
282 contact_directory: Option<ContactDirectory>,
283 event_log: Option<std::sync::Arc<dyn EventLogStore>>,
284) -> Router {
285 console_json_router_with_runtime_and_events(
286 decisions,
287 runtime,
288 None,
289 contact_directory,
290 event_log,
291 None,
292 None,
293 None,
294 None,
295 None,
296 None,
297 )
298}
299
300#[allow(clippy::too_many_arguments)]
301pub(crate) fn console_json_router_with_runtime_and_events(
302 decisions: RuntimeDecisionState,
303 runtime: MobRuntime,
304 module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
305 contact_directory: Option<ContactDirectory>,
306 event_log: Option<std::sync::Arc<dyn EventLogStore>>,
307 gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
308 console_events: Option<ConsoleEventStore>,
309 console_log_store: Option<std::sync::Arc<dyn ConsoleLogStore>>,
310 mob_events: Option<MobEventsStore>,
311 metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
312 identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
313) -> Router {
314 console_json_router_with_runtime_events_and_policy(
315 decisions,
316 runtime,
317 module_runtime,
318 contact_directory,
319 event_log,
320 gateway_peer_keys,
321 console_events,
322 console_log_store,
323 mob_events,
324 metadata_table,
325 identity_runtime,
326 Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
327 None,
328 None,
329 )
330}
331
332#[allow(clippy::too_many_arguments)]
333pub(crate) fn console_json_router_with_runtime_events_and_policy(
334 decisions: RuntimeDecisionState,
335 runtime: MobRuntime,
336 module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
337 contact_directory: Option<ContactDirectory>,
338 event_log: Option<std::sync::Arc<dyn EventLogStore>>,
339 gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
340 console_events: Option<ConsoleEventStore>,
341 console_log_store: Option<std::sync::Arc<dyn ConsoleLogStore>>,
342 mob_events: Option<MobEventsStore>,
343 metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
344 identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
345 visibility_policy: Arc<dyn ConsoleVisibilityPolicy>,
346 access: Option<AccessController>,
347 memory_panel: Option<crate::memory::sqlite_store::SqliteAgentMemoryStore>,
348) -> Router {
349 let console_aggregator = console_events.clone().map(|events| {
350 if let Some(store) = console_log_store {
351 let aggregator = MobKitConsoleAggregator::new(store);
352 aggregator.register_runtime_handles_with_policy(
353 "default",
354 "",
355 runtime.clone(),
356 identity_runtime.clone(),
357 events,
358 visibility_policy.clone(),
359 );
360 aggregator
361 } else {
362 let aggregator = MobKitConsoleAggregator::in_memory();
363 aggregator.register_runtime_handles_with_policy(
364 "default",
365 "",
366 runtime.clone(),
367 identity_runtime.clone(),
368 events,
369 visibility_policy.clone(),
370 );
371 aggregator
372 }
373 });
374 let snapshot_read_model = ConsoleSnapshotReadModel::default();
375 snapshot_read_model.refresh_soon(runtime.clone());
376 console_json_router_with_state(ConsoleJsonState {
377 decisions,
378 runtime: Some(runtime),
379 module_runtime,
380 contact_directory,
381 event_log,
382 gateway_peer_keys,
383 identity_runtime,
384 console_events,
385 console_aggregator,
386 mob_events,
387 metadata_table,
388 visibility_policy,
389 snapshot_read_model,
390 access,
391 memory_panel,
392 })
393}
394
395pub fn console_frontend_router() -> Router {
396 Router::new()
397 .route("/", get(|| async { Redirect::temporary("/console") }))
398 .route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT }))
399 .route("/console", get(console_frontend_index_handler))
400 .route("/console/", get(console_frontend_index_handler))
401 .route(
402 "/console/assets/console-app.js",
403 get(console_frontend_app_js_handler),
404 )
405 .route(
406 "/console/assets/console-app.css",
407 get(console_frontend_app_css_handler),
408 )
409}
410
411fn console_json_router_with_state(state: ConsoleJsonState) -> Router {
412 let router = Router::new()
413 .route("/console/experience", get(console_json_handler))
414 .route("/console/modules", get(console_json_handler))
415 .route("/console/identities", get(console_identities_handler))
416 .route("/console/timeline", get(console_timeline_handler))
417 .route(
418 "/console/timeline/stream",
419 get(console_timeline_stream_handler),
420 )
421 .route(
422 "/console/identity/{identity}/stream",
423 get(console_identity_timeline_stream_handler),
424 )
425 .route("/console/send", post(console_send_handler))
426 .route("/console/rpc", post(console_rpc_handler))
427 .route(
428 "/console/rpc/multipart",
429 post(console_rpc_multipart_handler)
430 .layer(DefaultBodyLimit::max(MAX_MULTIPART_BODY_BYTES)),
431 )
432 .route("/blobs/{blob_id}", get(blob_get_handler));
433 router.with_state(state)
434}
435
436pub async fn console_json_handler(
437 State(state): State<ConsoleJsonState>,
438 headers: HeaderMap,
439 uri: Uri,
440) -> impl IntoResponse {
441 let mut path = uri
442 .path_and_query()
443 .map(|path_and_query| path_and_query.as_str().to_string())
444 .unwrap_or_else(|| uri.path().to_string());
445
446 let already_has_token = path
458 .split_once('?')
459 .map(|(_, q)| form_urlencoded::parse(q.as_bytes()).any(|(key, _)| key == "auth_token"))
460 .unwrap_or(false);
461 if !already_has_token
462 && let Some(bearer) = headers
463 .get(header::AUTHORIZATION)
464 .and_then(|v| v.to_str().ok())
465 .and_then(extract_bearer_token_from_header)
466 {
467 let encoded: String = form_urlencoded::byte_serialize(bearer.as_bytes()).collect();
468 let sep = if path.contains('?') { '&' } else { '?' };
469 path = format!("{path}{sep}auth_token={encoded}");
470 }
471
472 let config_module_ids: Vec<String> = state
473 .decisions
474 .modules
475 .iter()
476 .map(|m| m.id.clone())
477 .collect();
478 let live_snapshot = match &state.runtime {
479 Some(runtime) => {
480 state.snapshot_read_model.refresh_soon(runtime.clone());
481 Some(
482 Box::pin(build_live_snapshot(
483 runtime,
484 &config_module_ids,
485 state.console_events.as_ref(),
486 state.visibility_policy.as_ref(),
487 &state.snapshot_read_model,
488 ))
489 .await,
490 )
491 }
492 None => match &state.console_aggregator {
493 Some(aggregator) => Box::pin(build_aggregator_live_snapshot(
494 aggregator,
495 &config_module_ids,
496 ))
497 .await
498 .ok(),
499 None => None,
500 },
501 }
502 .map(|mut snapshot| {
503 apply_console_visibility_policy(&mut snapshot, state.visibility_policy.as_ref());
504 snapshot
505 });
506
507 let response = handle_console_rest_json_route_with_snapshot_access_and_memory(
508 &state.decisions,
509 &ConsoleRestJsonRequest {
510 method: "GET".to_string(),
511 path,
512 auth: None,
513 },
514 live_snapshot.as_ref(),
515 state.access.as_ref(),
516 state.memory_panel.is_some(),
517 );
518 let status = StatusCode::from_u16(response.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
519 (status, Json::<Value>(response.body))
520}
521
522pub async fn console_rpc_handler(
523 State(state): State<ConsoleJsonState>,
524 headers: HeaderMap,
525 uri: Uri,
526 Json(request): Json<Value>,
527) -> impl IntoResponse {
528 let parsed_request = match serde_json::from_value::<JsonRpcRequest>(request) {
530 Ok(req) => req,
531 Err(_) => {
532 return (
533 StatusCode::OK,
534 Json::<Value>(serde_json::json!({
535 "jsonrpc": JSONRPC_VERSION,
536 "id": Value::Null,
537 "error": { "code": -32600, "message": "Invalid Request" }
538 })),
539 );
540 }
541 };
542
543 let auth_context = match console_request_auth_context(&state, &headers, &uri) {
549 Some(context) => context,
550 None => {
551 return (
552 StatusCode::UNAUTHORIZED,
553 Json::<Value>(serde_json::json!({
554 "jsonrpc": JSONRPC_VERSION,
555 "id": parsed_request.id.unwrap_or(Value::Null),
556 "error": {
557 "code": -32600,
558 "message": "unauthorized: console rpc requires a valid auth token",
559 }
560 })),
561 );
562 }
563 };
564 let is_authenticated = true;
569 let read_only = state.decisions.console.read_only;
570 let Some(runtime) = &state.runtime else {
571 let response_value = Box::pin(handle_console_aggregator_rpc(
572 state.console_aggregator.clone(),
573 parsed_request,
574 is_authenticated,
575 read_only,
576 state.access.as_ref(),
577 auth_context.access_view.as_ref(),
578 ))
579 .await;
580 return (StatusCode::OK, Json::<Value>(response_value));
581 };
582
583 let response_value = Box::pin(handle_console_runtime_rpc_with_visibility(
584 runtime,
585 state.module_runtime.clone(),
586 state.contact_directory.as_ref(),
587 state.gateway_peer_keys.as_ref(),
588 state.console_events.clone(),
589 state.console_aggregator.clone(),
590 state.identity_runtime.clone(),
591 state.metadata_table.clone(),
592 state.mob_events.clone(),
593 state.visibility_policy.as_ref(),
594 parsed_request,
595 is_authenticated,
596 read_only,
597 auth_context.principal.as_deref(),
598 state.access.as_ref(),
599 auth_context.access_view.as_ref(),
600 state.memory_panel.as_ref(),
601 ))
602 .await;
603 (StatusCode::OK, Json::<Value>(response_value))
604}
605
606#[derive(Debug, serde::Deserialize)]
607struct ConsoleTimelineHttpQuery {
608 #[serde(default)]
609 identity: Option<String>,
610 #[serde(default)]
611 conversation_id: Option<String>,
612 #[serde(default)]
613 after: Option<String>,
614 #[serde(default)]
615 before: Option<String>,
616 #[serde(default)]
617 mode: Option<ConsoleTimelineMode>,
618 #[serde(default)]
619 limit: Option<usize>,
620}
621
622async fn console_identities_handler(
623 State(state): State<ConsoleJsonState>,
624 headers: HeaderMap,
625 uri: Uri,
626) -> impl IntoResponse {
627 let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
628 return console_json_error(
629 StatusCode::UNAUTHORIZED,
630 "unauthorized",
631 "console identities require a valid auth token",
632 );
633 };
634 let Some(aggregator) = &state.console_aggregator else {
635 return console_json_error(
636 StatusCode::NOT_FOUND,
637 "unavailable",
638 "console aggregator unavailable",
639 );
640 };
641 let aggregator = aggregator.clone();
642 match Box::pin(aggregator.list_identities()).await {
643 Ok(mut identities) => {
644 retain_visible_identity_records(&mut identities, auth_context.access_view.as_ref());
645 (
646 StatusCode::OK,
647 Json::<Value>(json!({ "identities": identities })),
648 )
649 .into_response()
650 }
651 Err(err) => {
652 tracing::warn!(target: "mobkit::console", error = %err, "console identities request failed");
653 console_json_error(
654 StatusCode::INTERNAL_SERVER_ERROR,
655 "internal_error",
656 "console identities unavailable",
657 )
658 }
659 }
660}
661
662async fn console_timeline_handler(
663 State(state): State<ConsoleJsonState>,
664 headers: HeaderMap,
665 uri: Uri,
666 Query(query): Query<ConsoleTimelineHttpQuery>,
667) -> impl IntoResponse {
668 let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
669 return console_json_error(
670 StatusCode::UNAUTHORIZED,
671 "unauthorized",
672 "console timeline requires a valid auth token",
673 );
674 };
675 let Some(aggregator) = &state.console_aggregator else {
676 return console_json_error(
677 StatusCode::NOT_FOUND,
678 "unavailable",
679 "console aggregator unavailable",
680 );
681 };
682 if let Some(runtime) = &state.runtime
686 && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
687 {
688 prime_access_cache_from_runtime(runtime, controller).await;
689 }
690 let timeline_query = timeline_query_from_http(query, None);
691 match Box::pin(aggregator.query_timeline_windowed(timeline_query)).await {
692 Ok(mut page) => {
693 if let Some(view) = auth_context.access_view.as_ref() {
694 page.frames
695 .retain(|frame| view.can_view_agent(frame.identity.as_str()));
696 }
697 (
698 StatusCode::OK,
699 Json::<Value>(
700 serde_json::to_value(page).unwrap_or_else(|_| json!({ "frames": [] })),
701 ),
702 )
703 .into_response()
704 }
705 Err(err) => {
706 console_json_error(StatusCode::CONFLICT, "replay_unavailable", &err.to_string())
707 }
708 }
709}
710
711async fn console_send_handler(
712 State(state): State<ConsoleJsonState>,
713 headers: HeaderMap,
714 uri: Uri,
715 Json(request): Json<ConsoleSendRequest>,
716) -> impl IntoResponse {
717 let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
718 return console_json_error(
719 StatusCode::UNAUTHORIZED,
720 "unauthorized",
721 "console send requires a valid auth token",
722 );
723 };
724 if state.decisions.console.read_only {
725 return console_json_error(StatusCode::FORBIDDEN, "read_only", "console is read-only");
726 }
727 if let Some(runtime) = &state.runtime
735 && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
736 {
737 prime_access_cache_from_runtime(runtime, controller).await;
738 }
739 if let Some(view) = auth_context.access_view.as_ref()
740 && view.enforced()
741 && !view.allows_agent(ACTION_AGENT_SEND, request.identity.as_str())
742 {
743 return console_json_error(
744 StatusCode::FORBIDDEN,
745 "access_denied",
746 "you are not allowed to send to this agent",
747 );
748 }
749 let Some(aggregator) = &state.console_aggregator else {
750 return console_json_error(
751 StatusCode::NOT_FOUND,
752 "unavailable",
753 "console aggregator unavailable",
754 );
755 };
756 if let Some(identity_runtime) = &state.identity_runtime {
757 return match Box::pin(console_send_with_identity_first_fallback(
758 aggregator,
759 identity_runtime.clone(),
760 state.runtime.as_ref(),
761 state.console_events.as_ref(),
762 request,
763 ))
764 .await
765 {
766 Ok(accepted) => (
767 StatusCode::OK,
768 Json::<Value>(
769 serde_json::to_value(accepted).unwrap_or_else(|_| json!({ "accepted": true })),
770 ),
771 )
772 .into_response(),
773 Err(err) => console_send_error_response(err),
774 };
775 }
776 match Box::pin(aggregator.send(request)).await {
777 Ok(accepted) => (
778 StatusCode::OK,
779 Json::<Value>(
780 serde_json::to_value(accepted).unwrap_or_else(|_| json!({ "accepted": true })),
781 ),
782 )
783 .into_response(),
784 Err(err) => console_send_error_response(err),
785 }
786}
787
788async fn console_send_with_identity_first_fallback(
789 aggregator: &MobKitConsoleAggregator,
790 identity_runtime: Arc<crate::identity_first::IdentityRuntime>,
791 runtime: Option<&MobRuntime>,
792 console_events: Option<&ConsoleEventStore>,
793 request: ConsoleSendRequest,
794) -> Result<crate::console_aggregator::ConsoleInteractionAccepted, ConsoleSendError> {
795 let member_send_request = request.clone();
796 match Box::pin(console_send_identity_first(
797 aggregator,
798 identity_runtime,
799 runtime,
800 console_events,
801 request,
802 ))
803 .await
804 {
805 Err(ConsoleSendError::UnknownIdentity(_)) => {
806 Box::pin(aggregator.send(member_send_request)).await
807 }
808 result => result,
809 }
810}
811
812async fn console_send_identity_first(
813 aggregator: &MobKitConsoleAggregator,
814 identity_runtime: Arc<crate::identity_first::IdentityRuntime>,
815 runtime: Option<&MobRuntime>,
816 console_events: Option<&ConsoleEventStore>,
817 mut request: ConsoleSendRequest,
818) -> Result<crate::console_aggregator::ConsoleInteractionAccepted, ConsoleSendError> {
819 let requested_identity = request.identity.clone();
820 let parsed_identity = crate::identity_first::AgentIdentity::parse(request.identity.as_str())
821 .map_err(|err| ConsoleSendError::InvalidRequest(format!("invalid identity: {err}")))?;
822 let content: ContentInput = serde_json::from_value(request.content.clone())
823 .map_err(|err| ConsoleSendError::InvalidContent(err.to_string()))?;
824 if let ContentInput::Text(text) = &content
825 && text.trim().is_empty()
826 {
827 return Err(ConsoleSendError::InvalidContent(
828 "content must be non-empty".to_string(),
829 ));
830 }
831 if let ContentInput::Blocks(blocks) = &content
832 && blocks.is_empty()
833 {
834 return Err(ConsoleSendError::InvalidContent(
835 "content blocks must be non-empty".to_string(),
836 ));
837 }
838 let handling_mode = parse_identity_first_handling_mode(request.handling_mode.as_deref())?;
839
840 let (identity, status) = match identity_runtime.status(&parsed_identity).await {
841 Ok(status) => (parsed_identity, status),
842 Err(original_err) => {
843 let Some(canonical_identity) = Box::pin(resolve_console_send_identity_alias(
844 aggregator,
845 &requested_identity,
846 ))
847 .await
848 else {
849 return Err(identity_runtime_error_to_console_send_error(
850 requested_identity.as_str(),
851 original_err,
852 ));
853 };
854 let identity = crate::identity_first::AgentIdentity::parse(canonical_identity.as_str())
855 .map_err(|err| {
856 ConsoleSendError::InvalidRequest(format!("invalid aliased identity: {err}"))
857 })?;
858 let status = identity_runtime.status(&identity).await.map_err(|_| {
859 identity_runtime_error_to_console_send_error(
860 requested_identity.as_str(),
861 original_err,
862 )
863 })?;
864 request.identity = canonical_identity;
865 (identity, status)
866 }
867 };
868 let session_id = status
869 .session_id
870 .as_ref()
871 .map(std::string::ToString::to_string);
872 let runtime_member_id = status
873 .agent_runtime_id
874 .as_ref()
875 .map(|id| id.as_str().to_string());
876 let accepted = Box::pin(
877 aggregator.reserve_identity_first_interaction(request.clone(), session_id.as_deref()),
878 )
879 .await?;
880
881 if let Some(events) = console_events {
882 events
883 .reserve_interaction_value(
884 identity.as_str(),
885 runtime_member_id.as_deref(),
886 &accepted.interaction_id,
887 &request.origin,
888 request.content.clone(),
889 )
890 .await
891 .map_err(ConsoleSendError::State)?;
892 }
893
894 if let (Some(runtime), Some(events), Some(runtime_member_id)) = (
895 runtime,
896 console_events.cloned(),
897 runtime_member_id.as_deref(),
898 ) {
899 start_identity_first_console_live_projection(
900 runtime,
901 events,
902 identity.as_str(),
903 runtime_member_id,
904 )
905 .await;
906 }
907
908 if handling_mode == meerkat_core::types::HandlingMode::Steer {
909 match identity_runtime
910 .send_with_mode(&identity, &content, handling_mode)
911 .await
912 {
913 Ok(_) => {
914 if let Err(err) = aggregator
915 .mark_steer_interaction_delivered(
916 &accepted.input_frame_id,
917 &accepted.interaction_id,
918 )
919 .await
920 {
921 tracing::warn!(
922 identity = %identity,
923 error = %err,
924 "console identity-first steer was admitted but delivery status projection failed"
925 );
926 }
927 }
928 Err(err) => {
929 let _ = aggregator
930 .mark_interaction_delivery_failed(&accepted.input_frame_id)
931 .await;
932 if let Some(events) = console_events {
933 events
934 .record_lifecycle(
935 identity.as_str(),
936 "interaction_failed",
937 json!({
938 "interaction_id": accepted.interaction_id,
939 "origin": request.origin,
940 "error": err.to_string(),
941 }),
942 )
943 .await;
944 }
945 tracing::warn!(
946 identity = %identity,
947 error = %err,
948 "console identity-first steer was accepted but delivery failed"
949 );
950 return Err(identity_runtime_error_to_console_send_error(
951 identity.as_str(),
952 err,
953 ));
954 }
955 }
956 return Ok(accepted);
957 }
958
959 let dispatch_aggregator = aggregator.clone();
960 let dispatch_events = console_events.cloned();
961 let dispatch_identity = identity.clone();
962 let dispatch_content = content.clone();
963 let dispatch_origin = request.origin.clone();
964 let dispatch_accepted = accepted.clone();
965 tokio::spawn(async move {
966 match identity_runtime
967 .send_with_mode(&dispatch_identity, &dispatch_content, handling_mode)
968 .await
969 {
970 Ok(_) => {
971 if let Err(err) = dispatch_aggregator
972 .mark_interaction_delivered(&dispatch_accepted.input_frame_id)
973 .await
974 {
975 tracing::warn!(
976 identity = %dispatch_identity,
977 error = %err,
978 "console identity-first send was accepted but delivery status projection failed"
979 );
980 }
981 }
982 Err(err) => {
983 let _ = dispatch_aggregator
984 .mark_interaction_delivery_failed(&dispatch_accepted.input_frame_id)
985 .await;
986 if let Some(events) = dispatch_events {
987 events
988 .record_lifecycle(
989 dispatch_identity.as_str(),
990 "interaction_failed",
991 json!({
992 "interaction_id": dispatch_accepted.interaction_id,
993 "origin": dispatch_origin,
994 "error": err.to_string(),
995 }),
996 )
997 .await;
998 }
999 tracing::warn!(
1000 identity = %dispatch_identity,
1001 error = %err,
1002 "console identity-first send was accepted but delivery failed"
1003 );
1004 }
1005 }
1006 });
1007 Ok(accepted)
1008}
1009
1010async fn start_identity_first_console_live_projection(
1011 runtime: &MobRuntime,
1012 console_events: ConsoleEventStore,
1013 identity: &str,
1014 runtime_member_id: &str,
1015) {
1016 let identity = identity.to_string();
1017 let runtime_member_id = runtime_member_id.to_string();
1018 let mut stream = match runtime
1019 .handle()
1020 .subscribe_agent_events(&crate::member_comms_id::mob_member_id(
1021 runtime_member_id.as_str(),
1022 ))
1023 .await
1024 {
1025 Ok(stream) => stream,
1026 Err(err) => {
1027 tracing::warn!(
1028 identity = %identity,
1029 runtime_member_id = %runtime_member_id,
1030 error = %err,
1031 "console identity-first live projection could not subscribe to agent events"
1032 );
1033 return;
1034 }
1035 };
1036
1037 tokio::spawn(async move {
1038 while let Some(envelope) = stream.next().await {
1039 let event_type = agent_event_type(&envelope.payload).to_string();
1040 let unified = EventEnvelope {
1041 event_id: format!("evt-agent-{}", envelope.event_id),
1042 source: "agent".to_string(),
1043 timestamp_ms: envelope.timestamp_ms,
1044 event: UnifiedEvent::Agent {
1045 agent_id: runtime_member_id.clone(),
1046 event_type: event_type.clone(),
1047 payload: Some(crate::mob_handle_runtime::console_agent_event_payload(
1052 &envelope.payload,
1053 )),
1054 },
1055 };
1056 console_events.project_unified_event(&unified).await;
1057 if matches!(event_type.as_str(), "run_completed" | "run_failed") {
1058 break;
1059 }
1060 }
1061 });
1062}
1063
1064fn parse_identity_first_handling_mode(
1065 value: Option<&str>,
1066) -> Result<meerkat_core::types::HandlingMode, ConsoleSendError> {
1067 match value.unwrap_or("queue") {
1068 "queue" => Ok(meerkat_core::types::HandlingMode::Queue),
1069 "steer" => Ok(meerkat_core::types::HandlingMode::Steer),
1070 other => Err(ConsoleSendError::InvalidHandlingMode(other.to_string())),
1071 }
1072}
1073
1074async fn resolve_console_send_identity_alias(
1075 aggregator: &MobKitConsoleAggregator,
1076 requested_identity: &str,
1077) -> Option<String> {
1078 let identities = Box::pin(aggregator.list_identities()).await.ok()?;
1079 identities
1080 .into_iter()
1081 .find(|record| {
1082 record.identity == requested_identity || record.runtime_member_id == requested_identity
1083 })
1084 .map(|record| record.identity)
1085}
1086
1087fn identity_runtime_error_to_console_send_error(
1088 identity: &str,
1089 err: crate::identity_first::IdentityRuntimeError,
1090) -> ConsoleSendError {
1091 match err {
1092 crate::identity_first::IdentityRuntimeError::UnknownIdentity(_) => {
1093 ConsoleSendError::UnknownIdentity(identity.to_string())
1094 }
1095 crate::identity_first::IdentityRuntimeError::NotAddressable(_) => {
1096 ConsoleSendError::NotAddressable(identity.to_string())
1097 }
1098 crate::identity_first::IdentityRuntimeError::InvalidState { .. } => {
1099 ConsoleSendError::Retired(identity.to_string())
1100 }
1101 other => ConsoleSendError::Dispatch(other.to_string()),
1102 }
1103}
1104
1105async fn console_timeline_stream_handler(
1106 State(state): State<ConsoleJsonState>,
1107 headers: HeaderMap,
1108 uri: Uri,
1109 Query(query): Query<ConsoleTimelineHttpQuery>,
1110) -> impl IntoResponse {
1111 let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
1112 return console_json_error(
1113 StatusCode::UNAUTHORIZED,
1114 "unauthorized",
1115 "console timeline stream requires a valid auth token",
1116 );
1117 };
1118 let access_view = auth_context.access_view.filter(AccessView::enforced);
1119 if access_view.is_some()
1126 && let Some(runtime) = &state.runtime
1127 && let Some(controller) = state
1128 .access
1129 .as_ref()
1130 .filter(|controller| controller.enabled())
1131 {
1132 prime_access_cache_from_runtime(runtime, controller).await;
1133 }
1134 let Some(aggregator) = &state.console_aggregator else {
1135 return console_json_error(
1136 StatusCode::NOT_FOUND,
1137 "unavailable",
1138 "console aggregator unavailable",
1139 );
1140 };
1141 let aggregator = aggregator.clone();
1142 let last_event_id = headers
1143 .get("last-event-id")
1144 .and_then(|value| value.to_str().ok())
1145 .map(str::trim)
1146 .filter(|value| !value.is_empty())
1147 .map(ToString::to_string);
1148 let timeline_query = timeline_query_from_http(query, last_event_id);
1149 let mut rx = aggregator.subscribe();
1150 let (snapshot_frames, snapshot_cursor) =
1151 match Box::pin(query_timeline_snapshot(&aggregator, timeline_query.clone())).await {
1152 Ok(snapshot) => snapshot,
1153 Err(_) => {
1154 let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
1155 let requested_cursor = timeline_query
1156 .after
1157 .as_ref()
1158 .map(ToString::to_string)
1159 .unwrap_or_default();
1160 return (
1161 StatusCode::CONFLICT,
1162 Json::<Value>(
1163 serde_json::to_value(ConsoleReplayUnavailable {
1164 error: "replay_unavailable".to_string(),
1165 requested_cursor,
1166 latest_cursor,
1167 })
1168 .unwrap_or_else(|_| json!({ "error": "replay_unavailable" })),
1169 ),
1170 )
1171 .into_response();
1172 }
1173 };
1174 let identity = timeline_query.identity.clone();
1175 let conversation_id = timeline_query.conversation_id.clone();
1176 let snapshot_after = timeline_query.after.clone();
1177 let stream = stream! {
1178 if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::SnapshotStarted { after: snapshot_after }) {
1179 yield Ok::<Event, Infallible>(event);
1180 }
1181 let mut latest_cursor = snapshot_cursor;
1182 for frame in snapshot_frames {
1183 latest_cursor = Some(frame.cursor.clone());
1184 if access_view.as_ref().is_some_and(|view| !view.can_view_agent(frame.identity.as_str())) {
1185 continue;
1186 }
1187 if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::ConsoleFrame { frame }) {
1188 yield Ok::<Event, Infallible>(event);
1189 }
1190 }
1191 if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::SnapshotComplete { cursor: latest_cursor.clone() }) {
1192 yield Ok::<Event, Infallible>(event);
1193 }
1194 loop {
1195 match rx.recv().await {
1196 Ok(event) if timeline_event_matches(&event, identity.as_deref(), conversation_id.as_deref()) => {
1197 if !Box::pin(aggregator.timeline_event_visible(&event)).await {
1198 continue;
1199 }
1200 if let Some(view) = access_view.as_ref()
1201 && let Some(frame_identity) = timeline_event_identity(&event)
1202 && !view.can_view_agent(frame_identity)
1203 {
1204 continue;
1205 }
1206 if let Some(event_cursor) = timeline_event_cursor(&event)
1207 && let Some(current_cursor) = latest_cursor.as_ref()
1208 && !cursor_is_after(event_cursor, current_cursor)
1209 {
1210 continue;
1211 }
1212 if let Some(sse) = sse_event_from_timeline_event(&event) {
1213 if let Some(event_cursor) = timeline_event_cursor(&event) {
1214 latest_cursor = Some(event_cursor.clone());
1215 }
1216 yield Ok::<Event, Infallible>(sse);
1217 }
1218 }
1219 Ok(_) => {}
1220 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => break,
1221 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1222 }
1223 }
1224 };
1225 Sse::new(stream)
1226 .keep_alive(
1227 KeepAlive::new()
1228 .interval(DEFAULT_KEEP_ALIVE_INTERVAL)
1229 .text(KEEP_ALIVE_TEXT),
1230 )
1231 .into_response()
1232}
1233
1234async fn console_identity_timeline_stream_handler(
1235 State(state): State<ConsoleJsonState>,
1236 headers: HeaderMap,
1237 uri: Uri,
1238 AxumPath(identity): AxumPath<String>,
1239 Query(mut query): Query<ConsoleTimelineHttpQuery>,
1240) -> impl IntoResponse {
1241 query.identity = Some(identity);
1242 Box::pin(console_timeline_stream_handler(
1243 State(state),
1244 headers,
1245 uri,
1246 Query(query),
1247 ))
1248 .await
1249 .into_response()
1250}
1251
1252fn timeline_query_from_http(
1253 query: ConsoleTimelineHttpQuery,
1254 fallback_after: Option<String>,
1255) -> ConsoleTimelineWindowQuery {
1256 let after = fallback_after.or(query.after).map(ConsoleCursor::from);
1257 let before = query.before.map(ConsoleCursor::from);
1258 ConsoleTimelineWindowQuery {
1259 identity: query
1260 .identity
1261 .map(|value| value.trim().to_string())
1262 .filter(|value| !value.is_empty()),
1263 conversation_id: query
1264 .conversation_id
1265 .map(|value| value.trim().to_string())
1266 .filter(|value| !value.is_empty()),
1267 after,
1268 before,
1269 mode: query.mode.unwrap_or_default(),
1270 limit: query.limit.unwrap_or(200),
1271 }
1272}
1273
1274async fn query_timeline_snapshot(
1275 aggregator: &MobKitConsoleAggregator,
1276 mut query: ConsoleTimelineWindowQuery,
1277) -> ConsoleLogResult<(Vec<ConsoleFrame>, Option<ConsoleCursor>)> {
1278 const DEFAULT_SNAPSHOT_LIMIT: usize = 200;
1279 query.limit = if query.limit == 0 {
1280 DEFAULT_SNAPSHOT_LIMIT
1281 } else {
1282 query.limit
1283 };
1284 if query.after.is_none() && query.mode == ConsoleTimelineMode::Since {
1285 query.mode = ConsoleTimelineMode::Recent;
1286 }
1287 let mode = query.mode;
1288 match mode {
1289 ConsoleTimelineMode::Recent => {
1290 let page = Box::pin(aggregator.query_timeline_windowed(query)).await?;
1291 Ok((page.frames, page.latest_cursor.or(page.next_cursor)))
1292 }
1293 ConsoleTimelineMode::Since => {
1294 if let (Some(after), Some(latest)) =
1295 (query.after.as_ref(), aggregator.latest_cursor().await?)
1296 && let (Some(after_seq), Some(latest_seq)) = (after.seq(), latest.seq())
1297 && after_seq > latest_seq
1298 {
1299 return Err(std::io::Error::other(
1300 "timeline replay cursor is beyond the current store frontier",
1301 )
1302 .into());
1303 }
1304 let mut frames = Vec::new();
1305 let mut cursor = query.after.clone();
1306 let mut latest_cursor = None;
1307 loop {
1308 let page = Box::pin(aggregator.query_timeline_windowed(query.clone())).await?;
1309 latest_cursor = page.latest_cursor.clone().or(latest_cursor);
1310 if !page.frames.is_empty() {
1311 cursor = page
1312 .next_cursor
1313 .clone()
1314 .or_else(|| page.frames.last().map(|frame| frame.cursor.clone()));
1315 frames.extend(page.frames);
1316 } else if page.next_cursor.is_some() {
1317 cursor = page.next_cursor.clone();
1318 }
1319 if page.exhausted || page.next_cursor.is_none() {
1320 return Ok((frames, cursor.or(latest_cursor)));
1321 }
1322 if page.next_cursor == query.after {
1323 return Err(
1324 std::io::Error::other("timeline replay made no cursor progress").into(),
1325 );
1326 }
1327 query.after = page.next_cursor;
1328 }
1329 }
1330 }
1331}
1332
1333fn console_json_error(status: StatusCode, error: &str, message: &str) -> axum::response::Response {
1334 (
1335 status,
1336 Json::<Value>(json!({
1337 "error": error,
1338 "message": message,
1339 })),
1340 )
1341 .into_response()
1342}
1343
1344fn is_console_mutating_rpc_method(method: &str) -> bool {
1345 matches!(
1346 method,
1347 "mobkit/retire"
1348 | "mobkit/reset_all"
1349 | "mobkit/console/send"
1350 | "mobkit/blob/upload"
1351 | "mobkit/ensure_member"
1352 | "mobkit/retire_member"
1353 | "mobkit/respawn_member"
1354 | "mobkit/force_cancel_member"
1355 | "mobkit/cancel_flow"
1356 | "mobkit/collect_completed"
1357 | "mobkit/run_flow"
1358 | "mobkit/spawn_helper"
1359 | "mobkit/fork_helper"
1360 | "mobkit/attach_existing_session"
1361 | "mobkit/reconcile_edges"
1362 | "mobkit/cross_mob/wire_local"
1363 | "mobkit/cross_mob/unwire_local"
1364 | "mobkit/respawn"
1365 | "mobkit/reset"
1366 | "mobkit/delete_identity"
1367 | "mobkit/agent_memory/remember"
1368 | "mobkit/agent_memory/update"
1369 | "mobkit/agent_memory/forget"
1370 | "mobkit/gating/decide"
1371 | "mobkit/mob_labels/set"
1372 | "mobkit/mob_labels/delete"
1373 | "mobkit/run_labels/set"
1374 | "mobkit/run_labels/delete"
1375 | "mobkit/access/set"
1376 | "mobkit/access/enable"
1377 | "mobkit/access/rules/upsert"
1378 | "mobkit/access/rules/delete"
1379 | "mobkit/access/groups/set"
1380 | "mobkit/access/groups/delete"
1381 )
1382}
1383
1384fn console_read_only_rpc_error(response_id: Value) -> Value {
1385 response_value(
1386 response_id,
1387 None,
1388 Some(JsonRpcError {
1389 code: -32010,
1390 message: "console is read-only".to_string(),
1391 data: Some(json!({ "kind": "read_only" })),
1392 }),
1393 )
1394}
1395
1396fn console_send_error_response(err: ConsoleSendError) -> axum::response::Response {
1397 let (status, code) = match &err {
1398 ConsoleSendError::UnknownIdentity(_) => (StatusCode::NOT_FOUND, "unknown_identity"),
1399 ConsoleSendError::AmbiguousIdentity { .. } => {
1400 (StatusCode::CONFLICT, "ambiguous_live_identity_alias")
1401 }
1402 ConsoleSendError::NotAddressable(_) => (StatusCode::CONFLICT, "not_addressable"),
1403 ConsoleSendError::Retired(_) => (StatusCode::CONFLICT, "retired"),
1404 ConsoleSendError::InvalidContent(_)
1405 | ConsoleSendError::InvalidHandlingMode(_)
1406 | ConsoleSendError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"),
1407 ConsoleSendError::IdempotencyConflict(_) => (StatusCode::CONFLICT, "idempotency_conflict"),
1408 ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1409 (StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
1410 }
1411 };
1412 console_json_error(status, code, &console_send_public_message(&err))
1413}
1414
1415fn console_send_rpc_code(err: &ConsoleSendError) -> i64 {
1416 match err {
1417 ConsoleSendError::UnknownIdentity(_) => -32001,
1418 ConsoleSendError::AmbiguousIdentity { .. } => -32602,
1419 ConsoleSendError::NotAddressable(_) => -32002,
1420 ConsoleSendError::InvalidContent(_)
1421 | ConsoleSendError::InvalidHandlingMode(_)
1422 | ConsoleSendError::InvalidRequest(_) => -32602,
1423 ConsoleSendError::IdempotencyConflict(_) => -32009,
1424 ConsoleSendError::Retired(_) => -32004,
1425 ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1426 -32000
1427 }
1428 }
1429}
1430
1431fn console_send_rpc_error(response_id: Value, err: ConsoleSendError) -> Value {
1432 response_value(response_id, None, Some(console_send_json_rpc_error(err)))
1433}
1434
1435fn console_send_json_rpc_error(err: ConsoleSendError) -> JsonRpcError {
1436 let code = console_send_rpc_code(&err);
1437 JsonRpcError {
1438 code,
1439 message: console_send_public_message(&err),
1440 data: None,
1441 }
1442}
1443
1444fn console_send_public_message(err: &ConsoleSendError) -> String {
1445 match err {
1446 ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1447 tracing::warn!(target: "mobkit::console", error = %err, "console send internal error");
1448 "console send failed".to_string()
1449 }
1450 _ => err.to_string(),
1451 }
1452}
1453
1454fn timeline_event_matches(
1455 event: &ConsoleTimelineEvent,
1456 identity: Option<&str>,
1457 conversation_id: Option<&str>,
1458) -> bool {
1459 let frame = match event {
1460 ConsoleTimelineEvent::ConsoleFrame { frame }
1461 | ConsoleTimelineEvent::FrameUpdated { frame } => frame,
1462 ConsoleTimelineEvent::SnapshotStarted { .. }
1463 | ConsoleTimelineEvent::SnapshotComplete { .. }
1464 | ConsoleTimelineEvent::ReplayUnavailable { .. } => return true,
1465 };
1466 if identity.is_some_and(|value| frame.identity != value) {
1467 return false;
1468 }
1469 if conversation_id.is_some_and(|value| frame.conversation_id.as_deref() != Some(value)) {
1470 return false;
1471 }
1472 true
1473}
1474
1475fn timeline_event_cursor(event: &ConsoleTimelineEvent) -> Option<&ConsoleCursor> {
1476 match event {
1477 ConsoleTimelineEvent::ConsoleFrame { frame }
1478 | ConsoleTimelineEvent::FrameUpdated { frame } => Some(&frame.cursor),
1479 ConsoleTimelineEvent::SnapshotStarted { .. }
1480 | ConsoleTimelineEvent::SnapshotComplete { .. }
1481 | ConsoleTimelineEvent::ReplayUnavailable { .. } => None,
1482 }
1483}
1484
1485fn timeline_event_identity(event: &ConsoleTimelineEvent) -> Option<&str> {
1486 match event {
1487 ConsoleTimelineEvent::ConsoleFrame { frame }
1488 | ConsoleTimelineEvent::FrameUpdated { frame } => Some(frame.identity.as_str()),
1489 ConsoleTimelineEvent::SnapshotStarted { .. }
1490 | ConsoleTimelineEvent::SnapshotComplete { .. }
1491 | ConsoleTimelineEvent::ReplayUnavailable { .. } => None,
1492 }
1493}
1494
1495pub(crate) const ACCESS_DENIED_RPC_CODE: i64 = -32030;
1500
1501fn retain_visible_timeline_frames(page: &mut ConsoleTimelineWindowPage, view: Option<&AccessView>) {
1502 let Some(view) = view.filter(|view| view.enforced()) else {
1503 return;
1504 };
1505 page.frames
1506 .retain(|frame| view.can_view_agent(frame.identity.as_str()));
1507}
1508
1509fn retain_visible_member_rows(rows: &mut Vec<Value>, view: Option<&AccessView>) {
1513 let Some(view) = view.filter(|view| view.enforced()) else {
1514 return;
1515 };
1516 rows.retain(|row| {
1517 let labels: Option<std::collections::BTreeMap<String, String>> = row
1518 .get("labels")
1519 .and_then(|value| serde_json::from_value(value.clone()).ok());
1520 let agent_id = row
1521 .get("agent_identity")
1522 .or_else(|| row.get("member_id"))
1523 .or_else(|| row.get("agent_id"))
1524 .and_then(Value::as_str);
1525 let identity = labels
1526 .as_ref()
1527 .and_then(|labels| labels.get("agent_identity"))
1528 .map(String::as_str)
1529 .or_else(|| row.get("identity").and_then(Value::as_str))
1530 .or(agent_id);
1531 let role = row
1532 .get("role")
1533 .or_else(|| row.get("profile"))
1534 .and_then(Value::as_str);
1535 view.decide(
1536 ACTION_AGENT_VIEW,
1537 &AccessResource {
1538 identity,
1539 agent_id,
1540 role,
1541 labels: labels.as_ref(),
1542 },
1543 )
1544 .is_allow()
1545 });
1546}
1547
1548fn retain_visible_identity_records(
1549 identities: &mut Vec<ConsoleIdentityRecord>,
1550 view: Option<&AccessView>,
1551) {
1552 let Some(view) = view.filter(|view| view.enforced()) else {
1553 return;
1554 };
1555 identities.retain(|record| {
1556 view.decide(
1557 ACTION_AGENT_VIEW,
1558 &AccessResource {
1559 identity: Some(record.identity.as_str()),
1560 agent_id: Some(record.runtime_member_id.as_str()),
1561 role: None,
1562 labels: Some(&record.labels),
1563 },
1564 )
1565 .is_allow()
1566 });
1567}
1568
1569fn console_rpc_access_requirements(
1575 method: &str,
1576 params: &Value,
1577) -> Option<Vec<(&'static str, Option<String>)>> {
1578 let identity = normalized_console_rpc_string_param(params, "identity");
1579 let target = identity
1580 .clone()
1581 .or_else(|| normalized_console_rpc_string_param(params, "member_id"))
1582 .or_else(|| normalized_console_rpc_string_param(params, "agent_id"));
1583 let one = |action: &'static str, target: Option<String>| Some(vec![(action, target)]);
1584 match method {
1585 "mobkit/console/send" => one(ACTION_AGENT_SEND, identity),
1586 "mobkit/agent_memory/remember" => one(ACTION_AGENT_MEMORY_WRITE, identity),
1587 "mobkit/agent_memory/update" => one(ACTION_AGENT_MEMORY_WRITE, identity),
1589 "mobkit/agent_memory/forget" => one(ACTION_AGENT_MEMORY_DELETE, identity),
1590 "mobkit/agent_memory/recall" | "mobkit/agent_memory/manifest" => Some(vec![
1595 (ACTION_AGENT_MEMORY_READ, identity.clone()),
1596 (ACTION_AGENT_VIEW, identity),
1597 ]),
1598 "mobkit/memory/panel/records" => match memory_panel_scope_param(params) {
1605 Some(scope) if scope == "mob" => one(ACTION_MOB_MEMORY_READ, None),
1606 Some(scope) if scope == "operator" => one(ACTION_OPERATOR_MEMORY_READ, None),
1607 _ if identity.is_some() => Some(vec![
1608 (ACTION_AGENT_MEMORY_READ, identity.clone()),
1609 (ACTION_AGENT_VIEW, identity),
1610 ]),
1611 _ => None,
1612 },
1613 "mobkit/memory/panel/dreams" => one(ACTION_AGENT_MEMORY_READ, None),
1617 "mobkit/memory/panel/quarantine" => one(ACTION_MEMORY_QUARANTINE_REVIEW, None),
1618 "mobkit/retire"
1623 | "mobkit/retire_member"
1624 | "mobkit/force_cancel_member"
1625 | "mobkit/delete_identity" => one(ACTION_AGENT_RETIRE, target),
1626 "mobkit/respawn" | "mobkit/respawn_member" => one(ACTION_AGENT_RESPAWN, target),
1627 "mobkit/reset" | "mobkit/reset_all" => one(ACTION_AGENT_RESET, target),
1628 "mobkit/ensure_member"
1629 | "mobkit/spawn_helper"
1630 | "mobkit/fork_helper"
1631 | "mobkit/attach_existing_session"
1632 | "mobkit/run_flow"
1633 | "mobkit/cancel_flow"
1634 | "mobkit/collect_completed" => one(ACTION_AGENT_SPAWN, target),
1635 "mobkit/flow_status" | "mobkit/list_flows" | "mobkit/list_runs" => {
1638 one(ACTION_AGENT_SPAWN, None)
1639 }
1640 "mobkit/gating/decide" => one(ACTION_GATING_DECIDE, None),
1641 "mobkit/gating/pending" | "mobkit/gating/audit" => one(ACTION_GATING_VIEW, None),
1642 "mobkit/mob_events/query" | "mobkit/mob_events/subscribe" => one(ACTION_MOB_OBSERVE, None),
1643 "mobkit/reconcile_edges"
1644 | "mobkit/cross_mob/wire_local"
1645 | "mobkit/cross_mob/unwire_local"
1646 | "mobkit/mob_labels/set"
1647 | "mobkit/mob_labels/delete"
1648 | "mobkit/run_labels/set"
1649 | "mobkit/run_labels/delete"
1650 | "mobkit/routing/routes/list"
1655 | "mobkit/delivery/history"
1656 | "mobkit/cross_mob/directory"
1657 | "mobkit/cross_mob/peer_info"
1658 | "mobkit/mob_labels/get"
1659 | "mobkit/run_labels/get" => one(ACTION_RUNTIME_ADMIN, None),
1660 "mobkit/get_member"
1661 | "mobkit/member_status"
1662 | "mobkit/identity/resolved_tools"
1663 | "mobkit/inspect_identity"
1664 | "mobkit/status_identity"
1665 | "mobkit/console/inspect_identity" => one(ACTION_AGENT_VIEW, target),
1666 _ => None,
1667 }
1668}
1669
1670fn memory_panel_scope_param(params: &Value) -> Option<String> {
1671 normalized_console_rpc_string_param(params, "scope").filter(|scope| !scope.is_empty())
1672}
1673
1674fn normalized_console_rpc_string_param(params: &Value, key: &str) -> Option<String> {
1675 params
1676 .get(key)
1677 .and_then(Value::as_str)
1678 .map(str::trim)
1679 .map(str::to_string)
1680}
1681
1682fn console_rpc_access_violation(
1685 view: Option<&AccessView>,
1686 method: &str,
1687 params: &Value,
1688) -> Option<JsonRpcError> {
1689 let view = view.filter(|view| view.enforced())?;
1690 let requirements = console_rpc_access_requirements(method, params)?;
1691 for (action, target) in requirements {
1692 let allowed = match target {
1693 Some(ref identity) => view.allows_agent(action, identity),
1694 None => view.allows(action),
1695 };
1696 if !allowed {
1697 return Some(JsonRpcError {
1698 code: ACCESS_DENIED_RPC_CODE,
1699 message: format!("access denied: {action}"),
1700 data: Some(json!({
1701 "kind": "access_denied",
1702 "action": action,
1703 "resource": target,
1704 })),
1705 });
1706 }
1707 }
1708 None
1709}
1710
1711fn access_denied_rpc_error(response_id: Value, message: impl Into<String>) -> Value {
1712 response_value(
1713 response_id,
1714 None,
1715 Some(JsonRpcError {
1716 code: ACCESS_DENIED_RPC_CODE,
1717 message: message.into(),
1718 data: Some(json!({ "kind": "access_denied" })),
1719 }),
1720 )
1721}
1722
1723fn access_unavailable_rpc_error(response_id: Value) -> Value {
1724 response_value(
1725 response_id,
1726 None,
1727 Some(JsonRpcError {
1728 code: -32004,
1729 message: "access control is not configured on this runtime".to_string(),
1730 data: Some(json!({ "kind": "access_unavailable" })),
1731 }),
1732 )
1733}
1734
1735fn access_config_rpc_error(response_id: Value, err: crate::access::AccessConfigError) -> Value {
1736 response_value(
1737 response_id,
1738 None,
1739 Some(JsonRpcError {
1740 code: -32602,
1741 message: err.to_string(),
1742 data: Some(json!({ "kind": "invalid_access_config" })),
1743 }),
1744 )
1745}
1746
1747const MEMORY_PANEL_DEFAULT_LIMIT: usize = 50;
1752const MEMORY_PANEL_MAX_LIMIT: usize = 200;
1753const MEMORY_PANEL_CHAIN_MAX: usize = 32;
1754const MEMORY_PANEL_INJECTIONS_MAX: usize = 50;
1755const MEMORY_PANEL_DREAMS_DEFAULT: usize = 20;
1756const MEMORY_PANEL_DREAMS_MAX: usize = 100;
1757
1758fn memory_panel_unavailable(response_id: Value) -> Value {
1759 response_value(
1760 response_id,
1761 None,
1762 Some(JsonRpcError {
1763 code: -32601,
1764 message: "memory panel is not configured".to_string(),
1765 data: None,
1766 }),
1767 )
1768}
1769
1770fn memory_panel_store_error(
1771 response_id: Value,
1772 err: crate::identity_first::agent_memory::AgentMemoryError,
1773) -> Value {
1774 response_value(
1775 response_id,
1776 None,
1777 Some(crate::rpc::agent_memory_rpc_error("panel", err)),
1778 )
1779}
1780
1781fn memory_panel_limit_param(params: &Value, default: usize, max: usize) -> usize {
1782 params
1783 .get("limit")
1784 .and_then(Value::as_u64)
1785 .map(|limit| (limit as usize).clamp(1, max))
1786 .unwrap_or(default)
1787}
1788
1789fn encode_memory_panel_cursor(cursor: &(u64, String)) -> String {
1790 format!("{}:{}", cursor.0, cursor.1)
1791}
1792
1793fn parse_memory_panel_cursor(raw: &str) -> Option<(u64, String)> {
1794 let (ms, id) = raw.split_once(':')?;
1795 let ms = ms.parse::<u64>().ok()?;
1796 (!id.is_empty()).then(|| (ms, id.to_string()))
1797}
1798
1799fn memory_panel_scope_action(scope: &crate::memory::records::MemoryScope) -> &'static str {
1804 match scope {
1805 crate::memory::records::MemoryScope::Identity { .. } => ACTION_AGENT_MEMORY_READ,
1806 crate::memory::records::MemoryScope::Mob { .. } => ACTION_MOB_MEMORY_READ,
1807 crate::memory::records::MemoryScope::Operator { .. } => ACTION_OPERATOR_MEMORY_READ,
1808 crate::memory::records::MemoryScope::Realm { .. } => ACTION_AGENT_MEMORY_READ,
1809 }
1810}
1811
1812fn memory_panel_record_visible(
1822 view: Option<&AccessView>,
1823 record: &crate::memory::records::MemoryRecord,
1824) -> bool {
1825 let Some(view) = view.filter(|view| view.enforced()) else {
1826 return true;
1827 };
1828 let scope_allowed = match &record.scope {
1829 crate::memory::records::MemoryScope::Identity { identity, .. } => {
1830 view.allows_agent(ACTION_AGENT_MEMORY_READ, identity)
1831 && view.allows_agent(ACTION_AGENT_VIEW, identity)
1832 }
1833 crate::memory::records::MemoryScope::Mob { .. } => view.allows(ACTION_MOB_MEMORY_READ),
1834 crate::memory::records::MemoryScope::Operator { .. } => {
1835 view.allows(ACTION_OPERATOR_MEMORY_READ)
1836 }
1837 crate::memory::records::MemoryScope::Realm { .. } => view.allows(ACTION_AGENT_MEMORY_READ),
1838 };
1839 if !scope_allowed {
1840 return false;
1841 }
1842 if matches!(
1843 record.status,
1844 crate::memory::records::RecordStatus::Quarantined { .. }
1845 ) {
1846 return view.allows(ACTION_MEMORY_QUARANTINE_REVIEW);
1847 }
1848 true
1849}
1850
1851fn memory_panel_promotion_visible(
1858 view: Option<&AccessView>,
1859 scope_kind: &str,
1860 scope_key: &str,
1861) -> bool {
1862 let Some(view) = view.filter(|view| view.enforced()) else {
1863 return true;
1864 };
1865 match scope_kind {
1866 "identity" => {
1867 view.allows_agent(ACTION_AGENT_MEMORY_READ, scope_key)
1868 && view.allows_agent(ACTION_AGENT_VIEW, scope_key)
1869 }
1870 "mob" => view.allows(ACTION_MOB_MEMORY_READ),
1871 "operator" => view.allows(ACTION_OPERATOR_MEMORY_READ),
1872 "realm" => view.allows(ACTION_AGENT_MEMORY_READ),
1873 _ => false,
1874 }
1875}
1876
1877fn memory_panel_record_json(
1880 record: &crate::memory::records::MemoryRecord,
1881 include_body: bool,
1882) -> Value {
1883 let mut value = serde_json::to_value(record).unwrap_or(Value::Null);
1884 if !include_body && let Some(object) = value.as_object_mut() {
1885 object.remove("body");
1886 object.insert("body_bytes".to_string(), json!(record.body.len()));
1887 }
1888 value
1889}
1890
1891async fn memory_panel_realms(
1894 store: &crate::memory::sqlite_store::SqliteAgentMemoryStore,
1895 params: &Value,
1896) -> Result<Vec<String>, crate::identity_first::agent_memory::AgentMemoryError> {
1897 match normalized_console_rpc_string_param(params, "realm").filter(|realm| !realm.is_empty()) {
1898 Some(realm) => Ok(vec![realm]),
1899 None => store.panel_realms().await,
1900 }
1901}
1902
1903async fn handle_memory_panel_records(
1904 store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
1905 view: Option<&AccessView>,
1906 params: &Value,
1907 response_id: Value,
1908) -> Value {
1909 let Some(store) = store else {
1910 return memory_panel_unavailable(response_id);
1911 };
1912 let scope_kind = memory_panel_scope_param(params);
1913 if let Some(kind) = &scope_kind
1914 && !matches!(kind.as_str(), "identity" | "mob" | "operator" | "realm")
1915 {
1916 return invalid_params(
1917 response_id,
1918 format!("Invalid params: unknown scope '{kind}'"),
1919 );
1920 }
1921 let identity = normalized_console_rpc_string_param(params, "identity")
1922 .filter(|identity| !identity.is_empty());
1923 let scope_kind = scope_kind.or_else(|| identity.as_ref().map(|_| "identity".to_string()));
1924 let scope_key = normalized_console_rpc_string_param(params, "scope_key")
1925 .filter(|key| !key.is_empty())
1926 .or(identity);
1927 let status =
1928 normalized_console_rpc_string_param(params, "status").filter(|status| !status.is_empty());
1929 if let Some(status) = &status
1930 && !matches!(
1931 status.as_str(),
1932 "active" | "quarantined" | "superseded" | "tombstoned"
1933 )
1934 {
1935 return invalid_params(
1936 response_id,
1937 format!("Invalid params: unknown status '{status}'"),
1938 );
1939 }
1940 let limit =
1941 memory_panel_limit_param(params, MEMORY_PANEL_DEFAULT_LIMIT, MEMORY_PANEL_MAX_LIMIT);
1942 let cursor = normalized_console_rpc_string_param(params, "cursor")
1943 .filter(|cursor| !cursor.is_empty())
1944 .map(|raw| parse_memory_panel_cursor(&raw));
1945 let cursor = match cursor {
1946 Some(None) => {
1947 return invalid_params(response_id, "Invalid params: malformed cursor".to_string());
1948 }
1949 Some(Some(cursor)) => Some(cursor),
1950 None => None,
1951 };
1952 let realms = match memory_panel_realms(store, params).await {
1953 Ok(realms) => realms,
1954 Err(err) => return memory_panel_store_error(response_id, err),
1955 };
1956 let single_realm = realms.len() == 1;
1959 let mut records = Vec::new();
1960 let mut next_cursor = None;
1961 for realm in &realms {
1962 let page = match store
1963 .records_page(
1964 realm,
1965 scope_kind.as_deref(),
1966 scope_key.as_deref(),
1967 status.as_deref(),
1968 limit,
1969 if single_realm { cursor.clone() } else { None },
1970 )
1971 .await
1972 {
1973 Ok(page) => page,
1974 Err(err) => return memory_panel_store_error(response_id, err),
1975 };
1976 if single_realm {
1977 next_cursor = page.next_cursor.as_ref().map(encode_memory_panel_cursor);
1978 }
1979 records.extend(page.records);
1980 }
1981 records.retain(|record| memory_panel_record_visible(view, record));
1982 records.sort_by(|a, b| {
1983 b.updated_at_ms
1984 .cmp(&a.updated_at_ms)
1985 .then_with(|| b.id.cmp(&a.id))
1986 });
1987 records.truncate(limit);
1988 let rows: Vec<Value> = records
1989 .iter()
1990 .map(|record| memory_panel_record_json(record, false))
1991 .collect();
1992 response_value(
1993 response_id,
1994 Some(json!({
1995 "records": rows,
1996 "next_cursor": next_cursor,
1997 "realms": realms,
1998 })),
1999 None,
2000 )
2001}
2002
2003async fn handle_memory_panel_record(
2004 store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2005 view: Option<&AccessView>,
2006 params: &Value,
2007 response_id: Value,
2008) -> Value {
2009 let Some(store) = store else {
2010 return memory_panel_unavailable(response_id);
2011 };
2012 let Some(memory_id) = normalized_console_rpc_string_param(params, "memory_id")
2013 .filter(|memory_id| !memory_id.is_empty())
2014 else {
2015 return invalid_params(
2016 response_id,
2017 "Invalid params: memory_id is required".to_string(),
2018 );
2019 };
2020 let realms = match memory_panel_realms(store, params).await {
2021 Ok(realms) => realms,
2022 Err(err) => return memory_panel_store_error(response_id, err),
2023 };
2024 let mut found = None;
2025 for realm in &realms {
2026 match store.record_by_id(realm, &memory_id).await {
2027 Ok(Some(record)) => {
2028 found = Some((realm.clone(), record));
2029 break;
2030 }
2031 Ok(None) => {}
2032 Err(err) => return memory_panel_store_error(response_id, err),
2033 }
2034 }
2035 let Some((realm, record)) = found else {
2036 return response_value(
2037 response_id,
2038 None,
2039 Some(JsonRpcError {
2040 code: -32001,
2041 message: format!("unknown memory record '{memory_id}'"),
2042 data: None,
2043 }),
2044 );
2045 };
2046 if !memory_panel_record_visible(view, &record) {
2049 let action = if matches!(
2050 record.status,
2051 crate::memory::records::RecordStatus::Quarantined { .. }
2052 ) && view
2053 .is_some_and(|view| view.enforced() && !view.allows(ACTION_MEMORY_QUARANTINE_REVIEW))
2054 {
2055 ACTION_MEMORY_QUARANTINE_REVIEW
2056 } else {
2057 memory_panel_scope_action(&record.scope)
2058 };
2059 return response_value(
2060 response_id,
2061 None,
2062 Some(JsonRpcError {
2063 code: ACCESS_DENIED_RPC_CODE,
2064 message: format!("access denied: {action}"),
2065 data: Some(json!({
2066 "kind": "access_denied",
2067 "action": action,
2068 "resource": record.id,
2069 })),
2070 }),
2071 );
2072 }
2073 let chain = match store
2074 .supersede_chain(&realm, &memory_id, MEMORY_PANEL_CHAIN_MAX)
2075 .await
2076 {
2077 Ok(chain) => chain,
2078 Err(err) => return memory_panel_store_error(response_id, err),
2079 };
2080 let chain_rows: Vec<Value> = chain
2081 .iter()
2082 .filter(|link| memory_panel_record_visible(view, link))
2083 .map(|link| memory_panel_record_json(link, false))
2084 .collect();
2085 let injections = match store
2086 .injection_log_for_record(&realm, &memory_id, MEMORY_PANEL_INJECTIONS_MAX)
2087 .await
2088 {
2089 Ok(entries) => entries,
2090 Err(err) => return memory_panel_store_error(response_id, err),
2091 };
2092 response_value(
2093 response_id,
2094 Some(json!({
2095 "realm": realm,
2096 "record": memory_panel_record_json(&record, true),
2097 "chain": chain_rows,
2098 "injections": injections,
2099 })),
2100 None,
2101 )
2102}
2103
2104async fn handle_memory_panel_quarantine(
2105 store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2106 view: Option<&AccessView>,
2107 params: &Value,
2108 response_id: Value,
2109) -> Value {
2110 let Some(store) = store else {
2111 return memory_panel_unavailable(response_id);
2112 };
2113 let limit =
2114 memory_panel_limit_param(params, MEMORY_PANEL_DEFAULT_LIMIT, MEMORY_PANEL_MAX_LIMIT);
2115 let realms = match memory_panel_realms(store, params).await {
2116 Ok(realms) => realms,
2117 Err(err) => return memory_panel_store_error(response_id, err),
2118 };
2119 let mut records = Vec::new();
2120 let mut pending = Vec::new();
2121 for realm in &realms {
2122 match store.quarantined_records(realm, limit).await {
2123 Ok(rows) => records.extend(rows),
2124 Err(err) => return memory_panel_store_error(response_id, err),
2125 }
2126 match store.pending_promotions(realm).await {
2127 Ok(rows) => pending.extend(
2128 rows.into_iter()
2129 .filter(|promotion| {
2130 memory_panel_promotion_visible(
2131 view,
2132 &promotion.scope_kind,
2133 &promotion.scope_key,
2134 )
2135 })
2136 .map(|promotion| {
2137 json!({
2139 "realm": realm,
2140 "pending_id": promotion.pending_id,
2141 "record_id": promotion.record_id,
2142 "scope_kind": promotion.scope_kind,
2143 "scope_key": promotion.scope_key,
2144 "rationale": promotion.rationale,
2145 "status": promotion.status,
2146 "created_at_ms": promotion.created_at_ms,
2147 })
2148 }),
2149 ),
2150 Err(err) => return memory_panel_store_error(response_id, err),
2151 }
2152 }
2153 records.retain(|record| memory_panel_record_visible(view, record));
2157 records.sort_by_key(|record| std::cmp::Reverse(record.created_at_ms));
2158 records.truncate(limit);
2159 let rows: Vec<Value> = records
2162 .iter()
2163 .map(|record| memory_panel_record_json(record, false))
2164 .collect();
2165 response_value(
2166 response_id,
2167 Some(json!({
2168 "records": rows,
2169 "pending_promotions": pending,
2170 "realms": realms,
2171 })),
2172 None,
2173 )
2174}
2175
2176async fn handle_memory_panel_dreams(
2177 store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2178 params: &Value,
2179 response_id: Value,
2180) -> Value {
2181 let Some(store) = store else {
2182 return memory_panel_unavailable(response_id);
2183 };
2184 let limit =
2185 memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2186 let realms = match memory_panel_realms(store, params).await {
2187 Ok(realms) => realms,
2188 Err(err) => return memory_panel_store_error(response_id, err),
2189 };
2190 let mut runs = Vec::new();
2191 for realm in &realms {
2192 match store.dream_history(realm, limit).await {
2193 Ok(history) => runs.extend(history.into_iter().map(|run| (realm.clone(), run))),
2194 Err(err) => return memory_panel_store_error(response_id, err),
2195 }
2196 }
2197 runs.sort_by_key(|(_, run)| std::cmp::Reverse(run.last_op_at_ms));
2198 runs.truncate(limit);
2199 let rows: Vec<Value> = runs
2200 .iter()
2201 .map(|(realm, run)| {
2202 json!({
2203 "realm": realm,
2204 "run_id": run.run_id,
2205 "first_op_at_ms": run.first_op_at_ms,
2206 "last_op_at_ms": run.last_op_at_ms,
2207 "ops": run.ops,
2208 "op_kinds": run.op_kinds,
2209 "quarantined_ops": run.quarantined_ops,
2210 "memory_ids": run.memory_ids,
2211 "rationales": run.rationales,
2212 })
2213 })
2214 .collect();
2215 response_value(
2216 response_id,
2217 Some(json!({ "runs": rows, "realms": realms })),
2218 None,
2219 )
2220}
2221
2222fn access_status_result(access: Option<&AccessController>, view: Option<&AccessView>) -> Value {
2223 match (access, view) {
2224 (Some(controller), Some(view)) => {
2225 let (_, revision) = controller.snapshot();
2226 json!({
2227 "available": true,
2228 "enabled": view.enforced(),
2229 "revision": revision,
2230 "subject": view.subject(),
2231 "groups": view.groups().iter().collect::<Vec<_>>(),
2232 "is_admin": view.is_admin(),
2233 "can_administer": view.can_administer(),
2234 "actions": ACCESS_ACTIONS,
2235 })
2236 }
2237 _ => json!({
2238 "available": false,
2239 "enabled": false,
2240 "actions": ACCESS_ACTIONS,
2241 }),
2242 }
2243}
2244
2245fn handle_access_admin_rpc(
2248 access: Option<&AccessController>,
2249 view: Option<&AccessView>,
2250 request: &JsonRpcRequest,
2251) -> Option<Value> {
2252 if !request.method.starts_with("mobkit/access/") {
2253 return None;
2254 }
2255 let response_id = request.id.clone().unwrap_or(Value::Null);
2256 let controller = match request.method.as_str() {
2257 "mobkit/access/status" => {
2258 return Some(response_value(
2259 response_id,
2260 Some(access_status_result(access, view)),
2261 None,
2262 ));
2263 }
2264 _ => {
2265 let Some(controller) = access else {
2266 return Some(access_unavailable_rpc_error(response_id));
2267 };
2268 if !view.is_some_and(AccessView::can_administer) {
2269 return Some(access_denied_rpc_error(
2270 response_id,
2271 "access denied: access.admin",
2272 ));
2273 }
2274 controller
2275 }
2276 };
2277 let result = match request.method.as_str() {
2278 "mobkit/access/get" => {
2279 let (config, revision) = controller.snapshot();
2280 Ok(json!({ "config": &*config, "revision": revision }))
2281 }
2282 "mobkit/access/set" => {
2283 match serde_json::from_value(request.params.get("config").cloned().unwrap_or_default())
2284 {
2285 Ok(config) => controller
2286 .replace_config(config)
2287 .map(|revision| json!({ "revision": revision })),
2288 Err(err) => {
2289 return Some(invalid_params(
2290 response_id,
2291 format!("invalid access config: {err}"),
2292 ));
2293 }
2294 }
2295 }
2296 "mobkit/access/rules/upsert" => {
2297 match serde_json::from_value::<AccessRule>(
2298 request.params.get("rule").cloned().unwrap_or_default(),
2299 ) {
2300 Ok(rule) => controller
2301 .upsert_rule(rule)
2302 .map(|revision| json!({ "revision": revision })),
2303 Err(err) => {
2304 return Some(invalid_params(
2305 response_id,
2306 format!("invalid access rule: {err}"),
2307 ));
2308 }
2309 }
2310 }
2311 "mobkit/access/rules/delete" => {
2312 let Some(rule_id) = request.params.get("id").and_then(Value::as_str) else {
2313 return Some(invalid_params(response_id, "id required"));
2314 };
2315 controller
2316 .delete_rule(rule_id)
2317 .map(|revision| json!({ "revision": revision }))
2318 }
2319 "mobkit/access/groups/set" => {
2320 let Some(name) = request.params.get("name").and_then(Value::as_str) else {
2321 return Some(invalid_params(response_id, "name required"));
2322 };
2323 match serde_json::from_value::<AccessGroup>(
2324 request.params.get("group").cloned().unwrap_or_default(),
2325 ) {
2326 Ok(group) => controller
2327 .set_group(name, group)
2328 .map(|revision| json!({ "revision": revision })),
2329 Err(err) => {
2330 return Some(invalid_params(
2331 response_id,
2332 format!("invalid access group: {err}"),
2333 ));
2334 }
2335 }
2336 }
2337 "mobkit/access/groups/delete" => {
2338 let Some(name) = request.params.get("name").and_then(Value::as_str) else {
2339 return Some(invalid_params(response_id, "name required"));
2340 };
2341 controller
2342 .delete_group(name)
2343 .map(|revision| json!({ "revision": revision }))
2344 }
2345 "mobkit/access/enable" => {
2346 let Some(enabled) = request.params.get("enabled").and_then(Value::as_bool) else {
2347 return Some(invalid_params(response_id, "enabled (bool) required"));
2348 };
2349 controller
2350 .set_enabled(enabled)
2351 .map(|revision| json!({ "revision": revision }))
2352 }
2353 "mobkit/access/preview" => {
2354 let subject = request.params.get("subject").and_then(Value::as_str);
2355 let Some(action) = request.params.get("action").and_then(Value::as_str) else {
2356 return Some(invalid_params(response_id, "action required"));
2357 };
2358 let identity = request.params.get("identity").and_then(Value::as_str);
2359 let preview_view = controller.view_for_subject(subject);
2360 let decision = match identity {
2361 Some(identity) => preview_view.decide_agent(action, identity),
2362 None => preview_view.decide(action, &AccessResource::none()),
2363 };
2364 Ok(json!({
2365 "subject": subject,
2366 "action": action,
2367 "identity": identity,
2368 "allowed": decision.is_allow(),
2369 "reason": decision.reason(),
2370 "groups": preview_view.groups().iter().collect::<Vec<_>>(),
2371 "is_admin": preview_view.is_admin(),
2372 }))
2373 }
2374 _ => {
2375 return Some(response_value(
2376 response_id,
2377 None,
2378 Some(JsonRpcError {
2379 code: -32601,
2380 message: "Method not found".to_string(),
2381 data: None,
2382 }),
2383 ));
2384 }
2385 };
2386 Some(match result {
2387 Ok(value) => response_value(response_id, Some(value), None),
2388 Err(err) => access_config_rpc_error(response_id, err),
2389 })
2390}
2391
2392fn cursor_is_after(candidate: &ConsoleCursor, current: &ConsoleCursor) -> bool {
2393 match (candidate.seq(), current.seq()) {
2394 (Some(candidate), Some(current)) => candidate > current,
2395 _ => candidate > current,
2396 }
2397}
2398
2399fn sse_event_from_timeline_event(event: &ConsoleTimelineEvent) -> Option<Event> {
2400 let (event_name, id) = match event {
2401 ConsoleTimelineEvent::SnapshotStarted { .. } => ("snapshot_started", None),
2402 ConsoleTimelineEvent::ConsoleFrame { frame } => (
2403 if frame.kind == "frame_updated" {
2404 "frame_updated"
2405 } else {
2406 "console_frame"
2407 },
2408 Some(frame.cursor.to_string()),
2409 ),
2410 ConsoleTimelineEvent::FrameUpdated { frame } => {
2411 ("frame_updated", Some(frame.cursor.to_string()))
2412 }
2413 ConsoleTimelineEvent::SnapshotComplete { cursor } => (
2414 "snapshot_complete",
2415 cursor.as_ref().map(ToString::to_string),
2416 ),
2417 ConsoleTimelineEvent::ReplayUnavailable { .. } => ("replay_unavailable", None),
2418 };
2419 let data = match serde_json::to_string(event) {
2420 Ok(value) => value,
2421 Err(_) => return None,
2422 };
2423 let mut sse = Event::default().event(event_name).data(data);
2424 if let Some(id) = id {
2425 sse = sse.id(id);
2426 }
2427 Some(sse)
2428}
2429
2430pub async fn console_rpc_multipart_handler(
2431 State(state): State<ConsoleJsonState>,
2432 headers: HeaderMap,
2433 uri: Uri,
2434 mut multipart: Multipart,
2435) -> impl IntoResponse {
2436 let auth_context = match console_request_auth_context(&state, &headers, &uri) {
2437 Some(context) => context,
2438 None => {
2439 return (
2440 StatusCode::UNAUTHORIZED,
2441 Json::<Value>(serde_json::json!({
2442 "jsonrpc": JSONRPC_VERSION,
2443 "id": Value::Null,
2444 "error": {
2445 "code": -32600,
2446 "message": "unauthorized: console rpc requires a valid auth token",
2447 }
2448 })),
2449 );
2450 }
2451 };
2452
2453 let mut payload: Option<String> = None;
2454 let mut files: std::collections::BTreeMap<String, MultipartImageUpload> =
2455 std::collections::BTreeMap::new();
2456
2457 while let Some(mut field) = match multipart.next_field().await {
2458 Ok(field) => field,
2459 Err(err) => {
2460 return (
2461 StatusCode::BAD_REQUEST,
2462 Json::<Value>(json_rpc_error_value(
2463 Value::Null,
2464 -32602,
2465 format!("invalid multipart body: {err}"),
2466 )),
2467 );
2468 }
2469 } {
2470 let name = field.name().unwrap_or("").to_string();
2471 if name == "payload" {
2472 if payload.is_some() {
2473 return (
2474 StatusCode::BAD_REQUEST,
2475 Json::<Value>(json_rpc_error_value(
2476 Value::Null,
2477 -32602,
2478 "duplicate payload part",
2479 )),
2480 );
2481 }
2482 payload = match field.text().await {
2483 Ok(text) => Some(text),
2484 Err(err) => {
2485 return (
2486 StatusCode::BAD_REQUEST,
2487 Json::<Value>(json_rpc_error_value(
2488 Value::Null,
2489 -32602,
2490 format!("invalid payload part: {err}"),
2491 )),
2492 );
2493 }
2494 };
2495 continue;
2496 }
2497
2498 let Some(upload_id) = name.strip_prefix("file:").filter(|id| !id.is_empty()) else {
2499 return (
2500 StatusCode::BAD_REQUEST,
2501 Json::<Value>(json_rpc_error_value(
2502 Value::Null,
2503 -32602,
2504 format!("unexpected multipart field: {name}"),
2505 )),
2506 );
2507 };
2508 if files.len() >= MAX_MULTIPART_IMAGES {
2509 return (
2510 StatusCode::BAD_REQUEST,
2511 Json::<Value>(json_rpc_error_value(
2512 Value::Null,
2513 -32602,
2514 format!("too many image attachments; max {MAX_MULTIPART_IMAGES}"),
2515 )),
2516 );
2517 }
2518 if files.contains_key(upload_id) {
2519 return (
2520 StatusCode::BAD_REQUEST,
2521 Json::<Value>(json_rpc_error_value(
2522 Value::Null,
2523 -32602,
2524 format!("duplicate file part for upload_id {upload_id}"),
2525 )),
2526 );
2527 }
2528 let media_type = field
2529 .content_type()
2530 .map(str::to_string)
2531 .unwrap_or_else(|| "application/octet-stream".to_string());
2532 if !is_allowed_image_media_type(&media_type) {
2533 return (
2534 StatusCode::BAD_REQUEST,
2535 Json::<Value>(json_rpc_error_value(
2536 Value::Null,
2537 -32602,
2538 format!("unsupported image media type: {media_type}"),
2539 )),
2540 );
2541 }
2542 let mut bytes = bytes::BytesMut::new();
2543 loop {
2544 let chunk = match field.chunk().await {
2545 Ok(chunk) => chunk,
2546 Err(err) => {
2547 return (
2548 StatusCode::BAD_REQUEST,
2549 Json::<Value>(json_rpc_error_value(
2550 Value::Null,
2551 -32602,
2552 format!("invalid file part {upload_id}: {err}"),
2553 )),
2554 );
2555 }
2556 };
2557 let Some(chunk) = chunk else {
2558 break;
2559 };
2560 if bytes.len() + chunk.len() > MAX_MULTIPART_IMAGE_BYTES {
2561 return (
2562 StatusCode::BAD_REQUEST,
2563 Json::<Value>(json_rpc_error_value(
2564 Value::Null,
2565 -32602,
2566 format!("image attachment {upload_id} exceeds 25 MiB"),
2567 )),
2568 );
2569 }
2570 bytes.extend_from_slice(&chunk);
2571 }
2572 files.insert(
2573 upload_id.to_string(),
2574 MultipartImageUpload {
2575 media_type,
2576 bytes: bytes.freeze(),
2577 },
2578 );
2579 }
2580
2581 let payload = match payload {
2582 Some(payload) => payload,
2583 None => {
2584 return (
2585 StatusCode::BAD_REQUEST,
2586 Json::<Value>(json_rpc_error_value(
2587 Value::Null,
2588 -32602,
2589 "payload part required",
2590 )),
2591 );
2592 }
2593 };
2594 let mut parsed_request = match serde_json::from_str::<JsonRpcRequest>(&payload) {
2595 Ok(req) => req,
2596 Err(err) => {
2597 return (
2598 StatusCode::OK,
2599 Json::<Value>(json_rpc_error_value(
2600 Value::Null,
2601 -32600,
2602 format!("Invalid Request: {err}"),
2603 )),
2604 );
2605 }
2606 };
2607 let response_id = parsed_request.id.clone().unwrap_or(Value::Null);
2608 if state.decisions.console.read_only && is_console_mutating_rpc_method(&parsed_request.method) {
2609 return (
2610 StatusCode::OK,
2611 Json::<Value>(console_read_only_rpc_error(response_id)),
2612 );
2613 }
2614 match parsed_request.method.as_str() {
2615 "mobkit/console/send" => {
2616 let Some(aggregator) = &state.console_aggregator else {
2617 return (
2618 StatusCode::OK,
2619 Json::<Value>(invalid_params(
2620 response_id,
2621 "mobkit/console/send multipart requires a console aggregator",
2622 )),
2623 );
2624 };
2625 let Some(identity) = parsed_request
2626 .params
2627 .get("identity")
2628 .and_then(Value::as_str)
2629 else {
2630 return (
2631 StatusCode::OK,
2632 Json::<Value>(invalid_params(response_id, "identity required")),
2633 );
2634 };
2635 if let Some(runtime) = &state.runtime
2643 && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
2644 {
2645 prime_access_cache_from_runtime(runtime, controller).await;
2646 }
2647 if let Some(violation) = console_rpc_access_violation(
2648 auth_context.access_view.as_ref(),
2649 "mobkit/console/send",
2650 &parsed_request.params,
2651 ) {
2652 return (
2653 StatusCode::OK,
2654 Json::<Value>(response_value(response_id, None, Some(violation))),
2655 );
2656 }
2657 let binary_blob_store =
2658 match Box::pin(aggregator.binary_blob_store_for_identity(identity)).await {
2659 Ok(Some(store)) => store,
2660 Ok(None) => {
2661 return (
2662 StatusCode::OK,
2663 Json::<Value>(invalid_params(
2664 response_id,
2665 "binary blob store unavailable for identity",
2666 )),
2667 );
2668 }
2669 Err(err) => {
2670 return (
2671 StatusCode::OK,
2672 Json::<Value>(console_send_rpc_error(response_id, err)),
2673 );
2674 }
2675 };
2676 if let Err(message) = externalize_image_upload_placeholders(
2677 &mut parsed_request.params,
2678 files,
2679 binary_blob_store,
2680 )
2681 .await
2682 {
2683 return (
2684 StatusCode::OK,
2685 Json::<Value>(invalid_params(response_id, message)),
2686 );
2687 }
2688 }
2689 "mobkit/blob/upload" => {
2690 let Some(runtime) = &state.runtime else {
2691 return (
2692 StatusCode::NOT_FOUND,
2693 Json::<Value>(json_rpc_error_value(
2694 response_id,
2695 -32600,
2696 "mobkit/blob/upload multipart requires a unified runtime",
2697 )),
2698 );
2699 };
2700 let Some(binary_blob_store) = runtime.binary_blob_store() else {
2701 return (
2702 StatusCode::INTERNAL_SERVER_ERROR,
2703 Json::<Value>(json_rpc_error_value(
2704 response_id,
2705 -32000,
2706 "binary blob store unavailable",
2707 )),
2708 );
2709 };
2710 let result = match externalize_single_image_upload(
2711 &parsed_request.params,
2712 files,
2713 binary_blob_store,
2714 )
2715 .await
2716 {
2717 Ok(result) => result,
2718 Err(message) => {
2719 return (
2720 StatusCode::OK,
2721 Json::<Value>(invalid_params(response_id, message)),
2722 );
2723 }
2724 };
2725 return (
2726 StatusCode::OK,
2727 Json::<Value>(response_value(response_id, Some(result), None)),
2728 );
2729 }
2730 _ => {
2731 return (
2732 StatusCode::OK,
2733 Json::<Value>(invalid_params(
2734 response_id,
2735 "multipart RPC supports mobkit/console/send and mobkit/blob/upload only",
2736 )),
2737 );
2738 }
2739 }
2740 let response_value =
2741 if parsed_request.method == "mobkit/console/send" && state.runtime.is_none() {
2742 Box::pin(handle_console_aggregator_rpc(
2743 state.console_aggregator.clone(),
2744 parsed_request,
2745 true,
2746 state.decisions.console.read_only,
2747 state.access.as_ref(),
2748 auth_context.access_view.as_ref(),
2749 ))
2750 .await
2751 } else {
2752 let Some(runtime) = &state.runtime else {
2753 return (
2754 StatusCode::NOT_FOUND,
2755 Json::<Value>(json_rpc_error_value(
2756 response_id,
2757 -32600,
2758 "console rpc multipart requires a unified runtime",
2759 )),
2760 );
2761 };
2762 Box::pin(handle_console_runtime_rpc_with_visibility(
2763 runtime,
2764 state.module_runtime.clone(),
2765 state.contact_directory.as_ref(),
2766 state.gateway_peer_keys.as_ref(),
2767 state.console_events.clone(),
2768 state.console_aggregator.clone(),
2769 state.identity_runtime.clone(),
2770 state.metadata_table.clone(),
2771 state.mob_events.clone(),
2772 state.visibility_policy.as_ref(),
2773 parsed_request,
2774 true,
2775 state.decisions.console.read_only,
2776 auth_context.principal.as_deref(),
2777 state.access.as_ref(),
2778 auth_context.access_view.as_ref(),
2779 state.memory_panel.as_ref(),
2780 ))
2781 .await
2782 };
2783 (StatusCode::OK, Json::<Value>(response_value))
2784}
2785
2786pub async fn blob_get_handler(
2787 State(state): State<ConsoleJsonState>,
2788 headers: HeaderMap,
2789 uri: Uri,
2790 AxumPath(blob_id): AxumPath<String>,
2791) -> impl IntoResponse {
2792 if !console_request_authorized(&state, &headers, &uri) {
2793 return (
2794 StatusCode::UNAUTHORIZED,
2795 Json::<Value>(serde_json::json!({ "error": "unauthorized" })),
2796 )
2797 .into_response();
2798 }
2799 if !is_valid_blob_id_value(&blob_id) {
2800 return (
2801 StatusCode::BAD_REQUEST,
2802 Json::<Value>(serde_json::json!({ "error": "invalid_blob_id" })),
2803 )
2804 .into_response();
2805 }
2806 let blob_id = meerkat_core::BlobId::from(blob_id.as_str());
2807 let mut stores: Vec<std::sync::Arc<dyn BinaryBlobStore>> = Vec::new();
2808 if let Some(runtime) = &state.runtime
2809 && let Some(store) = runtime.binary_blob_store()
2810 {
2811 stores.push(store);
2812 }
2813 if let Some(aggregator) = &state.console_aggregator {
2814 stores.extend(aggregator.binary_blob_stores());
2815 }
2816 if stores.is_empty() {
2817 return (
2818 StatusCode::NOT_FOUND,
2819 Json::<Value>(serde_json::json!({ "error": "blob_store_unavailable" })),
2820 )
2821 .into_response();
2822 }
2823 for store in stores {
2824 match store.get_bytes(&blob_id).await {
2825 Ok(payload) => return blob_payload_response(payload),
2826 Err(meerkat_core::BlobStoreError::NotFound(_)) => continue,
2827 Err(err) => {
2828 return (
2829 StatusCode::INTERNAL_SERVER_ERROR,
2830 Json::<Value>(serde_json::json!({ "error": err.to_string() })),
2831 )
2832 .into_response();
2833 }
2834 }
2835 }
2836 (
2837 StatusCode::NOT_FOUND,
2838 Json::<Value>(serde_json::json!({ "error": "blob_not_found" })),
2839 )
2840 .into_response()
2841}
2842
2843fn blob_payload_response(payload: BinaryBlobPayload) -> axum::response::Response {
2844 let mut response_headers = HeaderMap::new();
2845 let content_type = HeaderValue::from_str(&payload.media_type)
2846 .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
2847 response_headers.insert(header::CONTENT_TYPE, content_type);
2848 if let Ok(content_length) = HeaderValue::from_str(&payload.size.to_string()) {
2849 response_headers.insert(header::CONTENT_LENGTH, content_length);
2850 }
2851 response_headers.insert(
2852 header::CACHE_CONTROL,
2853 HeaderValue::from_static("private, max-age=31536000, immutable"),
2854 );
2855 (StatusCode::OK, response_headers, payload.data).into_response()
2856}
2857
2858fn console_request_authorized(state: &ConsoleJsonState, headers: &HeaderMap, uri: &Uri) -> bool {
2859 console_request_auth_context(state, headers, uri).is_some()
2860}
2861
2862fn console_request_auth_context(
2863 state: &ConsoleJsonState,
2864 headers: &HeaderMap,
2865 uri: &Uri,
2866) -> Option<ConsoleHttpAuthContext> {
2867 if !state.decisions.console.require_app_auth {
2868 let principal = state.access.as_ref().and_then(|_| {
2871 let token = console_request_token(headers, uri)?;
2872 resolve_authorized_console_auth_from_token(&state.decisions, &token)
2873 .map(|auth| auth.email)
2874 });
2875 return Some(ConsoleHttpAuthContext {
2876 access_view: state
2877 .access
2878 .as_ref()
2879 .map(|controller| controller.view_for_subject(principal.as_deref())),
2880 principal,
2881 });
2882 }
2883 let token = console_request_token(headers, uri)?;
2884 let auth = resolve_authorized_console_auth_from_token(&state.decisions, &token)?;
2885 Some(ConsoleHttpAuthContext {
2886 access_view: state
2887 .access
2888 .as_ref()
2889 .map(|controller| controller.view_for_subject(Some(auth.email.as_str()))),
2890 principal: Some(auth.email),
2891 })
2892}
2893
2894fn console_request_token(headers: &HeaderMap, uri: &Uri) -> Option<String> {
2895 let bearer_token = headers
2896 .get(header::AUTHORIZATION)
2897 .and_then(|v| v.to_str().ok())
2898 .and_then(extract_bearer_token_from_header)
2899 .map(String::from);
2900 let query_token = uri.query().and_then(|q| {
2903 form_urlencoded::parse(q.as_bytes())
2904 .find(|(key, _)| key == "auth_token")
2905 .map(|(_, value)| value.into_owned())
2906 });
2907 bearer_token.or(query_token)
2908}
2909
2910#[derive(Debug)]
2911struct MultipartImageUpload {
2912 media_type: String,
2913 bytes: bytes::Bytes,
2914}
2915
2916fn json_rpc_error_value(id: Value, code: i64, message: impl Into<String>) -> Value {
2917 serde_json::json!({
2918 "jsonrpc": JSONRPC_VERSION,
2919 "id": id,
2920 "error": {
2921 "code": code,
2922 "message": message.into(),
2923 }
2924 })
2925}
2926
2927fn is_allowed_image_media_type(media_type: &str) -> bool {
2928 matches!(
2929 media_type,
2930 "image/png" | "image/jpeg" | "image/webp" | "image/gif"
2931 )
2932}
2933
2934fn image_upload_part_name<'a>(
2935 object: &'a serde_json::Map<String, Value>,
2936 context: &str,
2937) -> Result<&'a str, String> {
2938 object
2939 .get("upload_id")
2940 .or_else(|| object.get("part_name"))
2941 .and_then(Value::as_str)
2942 .map(str::trim)
2943 .filter(|value| !value.is_empty())
2944 .ok_or_else(|| format!("{context}.upload_id or {context}.part_name is required"))
2945}
2946
2947async fn externalize_image_upload_placeholders(
2948 params: &mut Value,
2949 files: std::collections::BTreeMap<String, MultipartImageUpload>,
2950 blob_store: std::sync::Arc<dyn crate::blob_store::BinaryBlobStore>,
2951) -> Result<(), String> {
2952 let Some(content) = params.get_mut("content") else {
2953 return Err("multipart payload params.content is required".to_string());
2954 };
2955 let mut placeholders = std::collections::BTreeMap::<String, String>::new();
2956 collect_image_upload_placeholders(content, &mut placeholders)?;
2957 if placeholders.is_empty() {
2958 return Err(
2959 "multipart payload must contain at least one image_upload placeholder".to_string(),
2960 );
2961 }
2962 if placeholders.len() > MAX_MULTIPART_IMAGES {
2963 return Err(format!(
2964 "too many image_upload placeholders; max {MAX_MULTIPART_IMAGES}"
2965 ));
2966 }
2967 for upload_id in files.keys() {
2968 if !placeholders.contains_key(upload_id) {
2969 return Err(format!(
2970 "file part has no matching image_upload placeholder: {upload_id}"
2971 ));
2972 }
2973 }
2974 for upload_id in placeholders.keys() {
2975 if !files.contains_key(upload_id) {
2976 return Err(format!(
2977 "image_upload placeholder missing file part: {upload_id}"
2978 ));
2979 }
2980 }
2981
2982 let mut refs = std::collections::BTreeMap::<String, Value>::new();
2983 for (upload_id, file) in files {
2984 let declared_media_type = placeholders
2985 .get(&upload_id)
2986 .cloned()
2987 .unwrap_or_else(|| file.media_type.clone());
2988 if !is_allowed_image_media_type(&declared_media_type) {
2989 return Err(format!(
2990 "unsupported image media type in placeholder {upload_id}: {declared_media_type}"
2991 ));
2992 }
2993 if declared_media_type != file.media_type {
2994 return Err(format!(
2995 "media type mismatch for {upload_id}: placeholder {declared_media_type}, file {}",
2996 file.media_type
2997 ));
2998 }
2999 let blob_ref = blob_store
3000 .put_bytes(&file.media_type, file.bytes)
3001 .await
3002 .map_err(|err| format!("failed to store image {upload_id}: {err}"))?;
3003 refs.insert(
3004 upload_id,
3005 serde_json::json!({
3006 "type": "image",
3007 "media_type": blob_ref.media_type,
3008 "source": "blob",
3009 "blob_id": blob_ref.blob_id,
3010 }),
3011 );
3012 }
3013 replace_image_upload_placeholders(content, &refs)?;
3014 if let Some(object) = params.as_object_mut() {
3015 object.remove("message");
3016 }
3017 Ok(())
3018}
3019
3020async fn externalize_single_image_upload(
3021 params: &Value,
3022 files: std::collections::BTreeMap<String, MultipartImageUpload>,
3023 blob_store: std::sync::Arc<dyn crate::blob_store::BinaryBlobStore>,
3024) -> Result<Value, String> {
3025 let upload = params.get("upload").unwrap_or(params);
3026 if upload
3027 .get("type")
3028 .and_then(Value::as_str)
3029 .is_some_and(|kind| kind != "image_upload")
3030 {
3031 return Err("upload.type must be image_upload".to_string());
3032 }
3033 let upload_object = upload
3034 .as_object()
3035 .ok_or_else(|| "upload must be an object".to_string())?;
3036 let upload_id = image_upload_part_name(upload_object, "upload")?;
3037 let Some(file) = files.get(upload_id) else {
3038 return Err(format!(
3039 "image_upload placeholder missing file part: {upload_id}"
3040 ));
3041 };
3042 if files.len() != 1 {
3043 return Err("mobkit/blob/upload accepts exactly one file part".to_string());
3044 }
3045 let declared_media_type = upload
3046 .get("media_type")
3047 .and_then(Value::as_str)
3048 .unwrap_or(file.media_type.as_str());
3049 if !is_allowed_image_media_type(declared_media_type) {
3050 return Err(format!(
3051 "unsupported image media type in upload {upload_id}: {declared_media_type}"
3052 ));
3053 }
3054 if declared_media_type != file.media_type {
3055 return Err(format!(
3056 "media type mismatch for {upload_id}: placeholder {declared_media_type}, file {}",
3057 file.media_type
3058 ));
3059 }
3060 let size = file.bytes.len() as u64;
3061 let blob_ref = blob_store
3062 .put_bytes(&file.media_type, file.bytes.clone())
3063 .await
3064 .map_err(|err| format!("failed to store image {upload_id}: {err}"))?;
3065 Ok(json!({
3066 "blob_id": blob_ref.blob_id,
3067 "media_type": blob_ref.media_type,
3068 "size": size,
3069 }))
3070}
3071
3072fn collect_image_upload_placeholders(
3073 value: &Value,
3074 placeholders: &mut std::collections::BTreeMap<String, String>,
3075) -> Result<(), String> {
3076 match value {
3077 Value::Array(items) => {
3078 for item in items {
3079 collect_image_upload_placeholders(item, placeholders)?;
3080 }
3081 }
3082 Value::Object(object) => {
3083 if object.get("type").and_then(Value::as_str) == Some("image_upload") {
3084 let upload_id = image_upload_part_name(object, "image_upload")?;
3085 let media_type = object
3086 .get("media_type")
3087 .and_then(Value::as_str)
3088 .map(str::trim)
3089 .filter(|value| !value.is_empty())
3090 .ok_or_else(|| format!("image_upload {upload_id} requires media_type"))?;
3091 if placeholders
3092 .insert(upload_id.to_string(), media_type.to_string())
3093 .is_some()
3094 {
3095 return Err(format!("duplicate image_upload placeholder: {upload_id}"));
3096 }
3097 } else {
3098 for child in object.values() {
3099 collect_image_upload_placeholders(child, placeholders)?;
3100 }
3101 }
3102 }
3103 _ => {}
3104 }
3105 Ok(())
3106}
3107
3108fn replace_image_upload_placeholders(
3109 value: &mut Value,
3110 refs: &std::collections::BTreeMap<String, Value>,
3111) -> Result<(), String> {
3112 match value {
3113 Value::Array(items) => {
3114 for item in items {
3115 replace_image_upload_placeholders(item, refs)?;
3116 }
3117 }
3118 Value::Object(object) => {
3119 if object.get("type").and_then(Value::as_str) == Some("image_upload") {
3120 let upload_id = image_upload_part_name(object, "image_upload")?;
3121 let replacement = refs
3122 .get(upload_id)
3123 .ok_or_else(|| format!("missing blob replacement for {upload_id}"))?;
3124 *value = replacement.clone();
3125 } else {
3126 for child in object.values_mut() {
3127 replace_image_upload_placeholders(child, refs)?;
3128 }
3129 }
3130 }
3131 _ => {}
3132 }
3133 Ok(())
3134}
3135
3136fn response_value(id: Value, result: Option<Value>, error: Option<JsonRpcError>) -> Value {
3137 serde_json::to_value(JsonRpcResponse {
3138 jsonrpc: JSONRPC_VERSION.to_string(),
3139 id,
3140 result,
3141 error,
3142 })
3143 .unwrap_or_else(|_| {
3144 serde_json::json!({
3145 "jsonrpc": JSONRPC_VERSION,
3146 "id": Value::Null,
3147 "error": {
3148 "code": -32603,
3149 "message": "serialization failed",
3150 }
3151 })
3152 })
3153}
3154
3155fn invalid_params(id: Value, message: impl Into<String>) -> Value {
3156 response_value(
3157 id,
3158 None,
3159 Some(JsonRpcError {
3160 code: -32602,
3161 message: message.into(),
3162 data: None,
3163 }),
3164 )
3165}
3166
3167fn gating_decision_failed_error(id: Value, err: impl std::fmt::Display) -> Value {
3168 tracing::warn!(
3169 target: "mobkit::console",
3170 error = %err,
3171 "console gating decision failed"
3172 );
3173 invalid_params(id, "gating decision failed")
3174}
3175
3176fn runtime_binding_from_wire(
3177 binding: WireRuntimeBinding,
3178) -> Result<meerkat_mob::RuntimeBinding, String> {
3179 match binding {
3180 WireRuntimeBinding::Session => Ok(meerkat_mob::RuntimeBinding::Session),
3181 WireRuntimeBinding::External {
3182 address,
3183 bootstrap_token,
3184 identity,
3185 } => {
3186 let resolved = identity.resolve().map_err(|err| err.to_string())?;
3187 Ok(meerkat_mob::RuntimeBinding::External {
3188 peer_id: resolved.peer_id.to_string(),
3189 address,
3190 bootstrap_token,
3191 pubkey: resolved.pubkey,
3192 })
3193 }
3194 }
3195}
3196
3197fn parse_optional_runtime_mode(params: &Value) -> Result<Option<MobRuntimeMode>, String> {
3198 match params.get("runtime_mode") {
3199 None | Some(Value::Null) => Ok(None),
3200 Some(value) => serde_json::from_value::<MobRuntimeMode>(value.clone())
3201 .map(Some)
3202 .map_err(|err| {
3203 format!("runtime_mode must be \"autonomous_host\" or \"turn_driven\": {err}")
3204 }),
3205 }
3206}
3207
3208fn parse_optional_backend(params: &Value) -> Result<Option<MobBackendKind>, String> {
3209 match params.get("backend") {
3210 None | Some(Value::Null) => Ok(None),
3211 Some(value) => serde_json::from_value::<MobBackendKind>(value.clone())
3212 .map(Some)
3213 .map_err(|err| format!("backend must be \"session\" or \"external\": {err}")),
3214 }
3215}
3216
3217fn parse_optional_runtime_binding(
3218 params: &Value,
3219) -> Result<Option<meerkat_mob::RuntimeBinding>, String> {
3220 match params.get("binding") {
3221 None | Some(Value::Null) => Ok(None),
3222 Some(value) => {
3223 let wire = serde_json::from_value::<WireRuntimeBinding>(value.clone())
3224 .map_err(|err| format!("binding: {err}"))?;
3225 runtime_binding_from_wire(wire).map(Some)
3226 }
3227 }
3228}
3229
3230async fn member_entry_to_console_json(
3231 runtime: &MobRuntime,
3232 entry: &meerkat_mob::runtime::MobMemberListEntry,
3233) -> Value {
3234 let mut value = member_entry_to_json(entry);
3235 if let Some(object) = value.as_object_mut() {
3236 object.insert(
3237 "model_capabilities".to_string(),
3238 serde_json::to_value(model_capabilities_for_member_entry(
3239 runtime.handle().definition(),
3240 entry,
3241 ))
3242 .unwrap_or(Value::Null),
3243 );
3244 }
3245 value
3246}
3247
3248fn internal_error(id: Value, message: impl Into<String>) -> Value {
3249 let message = message.into();
3250 tracing::warn!(
3251 target: "mobkit::console",
3252 error = %message,
3253 "console JSON-RPC internal error"
3254 );
3255 response_value(
3256 id,
3257 None,
3258 Some(JsonRpcError {
3259 code: -32000,
3260 message: "internal error".to_string(),
3261 data: Some(json!({ "error": "internal_error" })),
3262 }),
3263 )
3264}
3265
3266fn stale_event_cursor_response(id: Value, after_cursor: u64, latest_cursor: u64) -> Value {
3271 response_value(
3272 id,
3273 None,
3274 Some(JsonRpcError {
3275 code: crate::rpc::MOB_EVENTS_STALE_CURSOR_CODE,
3276 message: format!(
3277 "stale mob event cursor: requested {after_cursor}, latest {latest_cursor}"
3278 ),
3279 data: Some(serde_json::json!({
3280 "error": "event_query_stale",
3281 "after_cursor": after_cursor,
3282 "latest_cursor": latest_cursor,
3283 })),
3284 }),
3285 )
3286}
3287
3288fn console_timeline_replay_unavailable_response(
3289 id: Value,
3290 err: ConsoleLogError,
3291 requested_cursor: Option<&ConsoleCursor>,
3292 latest_cursor: Option<ConsoleCursor>,
3293) -> Value {
3294 tracing::warn!(
3295 target: "mobkit::console",
3296 error = %err,
3297 "console timeline replay unavailable"
3298 );
3299 response_value(
3300 id,
3301 None,
3302 Some(JsonRpcError {
3303 code: crate::rpc::CONSOLE_TIMELINE_REPLAY_UNAVAILABLE_CODE,
3304 message: "timeline replay unavailable".to_string(),
3305 data: Some(json!({
3306 "error": "replay_unavailable",
3307 "stream": "timeline",
3308 "requested_cursor": requested_cursor.map(ToString::to_string),
3309 "latest_cursor": latest_cursor.map(|cursor| cursor.to_string()),
3310 })),
3311 }),
3312 )
3313}
3314
3315fn parse_console_helper_options(
3316 options_val: Option<&Value>,
3317) -> Result<meerkat_mob::HelperOptions, String> {
3318 crate::rpc::mob_methods::parse_helper_options(options_val)
3319}
3320
3321fn member_is_addressable(member: &meerkat_mob::runtime::MobMemberListEntry) -> bool {
3322 member
3323 .labels
3324 .get("addressable")
3325 .map(|value: &String| !value.eq_ignore_ascii_case("false"))
3326 .unwrap_or(true)
3327}
3328
3329fn member_addressability(member: &meerkat_mob::runtime::MobMemberListEntry) -> &'static str {
3330 if member_is_addressable(member) {
3331 "addressable"
3332 } else {
3333 "internal_only"
3334 }
3335}
3336
3337fn console_identity_status_json_for_identity(
3338 identity: &str,
3339 member: &meerkat_mob::runtime::MobMemberListEntry,
3340 session_id: Option<String>,
3341 response_phase: Option<String>,
3342) -> Value {
3343 json!({
3344 "identity": identity,
3345 "state": member_status_state_string(member.status),
3346 "role": member.role.to_string(),
3347 "addressability": member_addressability(member),
3348 "display_name": member.labels.get("display_name"),
3349 "labels": member.labels,
3350 "agent_runtime_id": crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()),
3351 "session_id": session_id,
3352 "generation": Value::Null,
3353 "checkpoint_version": Value::Null,
3354 "continuity_health": Value::Null,
3355 "lease_healthy": Value::Null,
3356 "lease": Value::Null,
3357 "response_phase": response_phase,
3358 })
3359}
3360
3361fn console_identity_inspect_json_for_identity(
3362 identity: &str,
3363 member: &meerkat_mob::runtime::MobMemberListEntry,
3364 session_id: Option<String>,
3365 response_phase: Option<String>,
3366) -> Value {
3367 let peers: Vec<String> = member.wired_to.iter().map(ToString::to_string).collect();
3368 json!({
3369 "identity": identity,
3370 "state": member_status_state_string(member.status),
3371 "role": member.role.to_string(),
3372 "addressability": member_addressability(member),
3373 "display_name": member.labels.get("display_name"),
3374 "labels": member.labels,
3375 "continuity_health": Value::Null,
3376 "lease_healthy": Value::Null,
3377 "lease": Value::Null,
3378 "continuity": {
3379 "generation": Value::Null,
3380 "checkpoint_version": Value::Null,
3381 "session_id": session_id,
3382 "agent_runtime_id": crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()),
3383 },
3384 "topology_peers": peers,
3385 "output_preview": Value::Null,
3386 "response_phase": response_phase,
3387 })
3388}
3389
3390async fn lookup_member_with_session(
3394 handle: &MobHandle,
3395 identity: &AgentIdentity,
3396) -> Option<(meerkat_mob::runtime::MobMemberListEntry, Option<String>)> {
3397 let entries = handle.list_members_including_retiring().await;
3398 let entry = entries
3399 .into_iter()
3400 .find(|e| &e.agent_identity == identity)?;
3401 let session_id = handle
3402 .resolve_bridge_session_id_observation(identity)
3403 .await
3404 .map(|s| s.to_string());
3405 Some((entry, session_id))
3406}
3407
3408#[derive(Debug, Clone)]
3409struct ConsoleRuntimeIdentityAlias {
3410 identity: String,
3411 runtime_member_id: String,
3412 member: meerkat_mob::runtime::MobMemberListEntry,
3413 session_id: Option<String>,
3414}
3415
3416fn durable_identity_for_member(member: &meerkat_mob::runtime::MobMemberListEntry) -> String {
3417 member
3418 .labels
3419 .get("agent_identity")
3420 .filter(|value| !value.trim().is_empty())
3421 .cloned()
3422 .unwrap_or_else(|| {
3424 crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned()
3425 })
3426}
3427
3428async fn lookup_member_alias_with_session(
3429 handle: &MobHandle,
3430 visibility_policy: &dyn ConsoleVisibilityPolicy,
3431 requested_identity: &str,
3432) -> Result<Option<ConsoleRuntimeIdentityAlias>, JsonRpcError> {
3433 let all_matches = lookup_member_alias_candidates_with_session(handle, requested_identity).await;
3434 let mut visible_matches = Vec::new();
3435 for alias in &all_matches {
3436 if runtime_alias_visible_to_console(handle, visibility_policy, alias) {
3437 visible_matches.push(alias.clone());
3438 }
3439 }
3440 let member = if visible_matches.len() > 1 {
3441 return Err(ambiguous_live_identity_alias_error(
3442 requested_identity,
3443 &visible_matches
3444 .iter()
3445 .map(|alias| alias.runtime_member_id.clone())
3446 .collect::<Vec<_>>(),
3447 ));
3448 } else if let Some(alias) = visible_matches.into_iter().next() {
3449 Some(alias)
3450 } else {
3451 all_matches.into_iter().next()
3452 };
3453 Ok(member)
3454}
3455
3456async fn lookup_visible_member_alias_candidates_with_session(
3457 handle: &MobHandle,
3458 visibility_policy: &dyn ConsoleVisibilityPolicy,
3459 requested_identity: &str,
3460) -> Vec<ConsoleRuntimeIdentityAlias> {
3461 let mut visible = Vec::new();
3462 for alias in lookup_member_alias_candidates_with_session(handle, requested_identity).await {
3463 if runtime_alias_visible_to_console(handle, visibility_policy, &alias) {
3464 visible.push(alias);
3465 }
3466 }
3467 visible
3468}
3469
3470async fn lookup_member_alias_candidates_with_session(
3471 handle: &MobHandle,
3472 requested_identity: &str,
3473) -> Vec<ConsoleRuntimeIdentityAlias> {
3474 let requested_member_id = crate::member_comms_id::mob_member_id(requested_identity);
3477 let entries = handle.list_members_including_retiring().await;
3478 let exact_matches = entries
3479 .iter()
3480 .filter(|entry| entry.agent_identity == requested_member_id)
3481 .cloned()
3482 .collect::<Vec<_>>();
3483 let label_matches = entries
3484 .iter()
3485 .filter(|entry| {
3486 entry
3487 .labels
3488 .get("agent_identity")
3489 .is_some_and(|identity| identity == requested_identity)
3490 })
3491 .cloned()
3492 .collect::<Vec<_>>();
3493 let mut matches = exact_matches;
3494 matches.extend(label_matches);
3495 let mut seen_member_ids = BTreeSet::new();
3496 matches.retain(|entry| seen_member_ids.insert(entry.agent_identity.to_string()));
3497 let mut aliases = Vec::with_capacity(matches.len());
3498 for member in matches {
3499 let runtime_member_id =
3500 crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned();
3501 let identity = durable_identity_for_member(&member);
3502 let session_id = handle
3503 .resolve_bridge_session_id_observation(&member.agent_identity)
3504 .await
3505 .map(|s| s.to_string());
3506 aliases.push(ConsoleRuntimeIdentityAlias {
3507 identity,
3508 runtime_member_id,
3509 member,
3510 session_id,
3511 });
3512 }
3513 aliases
3514}
3515
3516async fn reject_ambiguous_projected_live_identity(
3517 handle: &MobHandle,
3518 visibility_policy: &dyn ConsoleVisibilityPolicy,
3519 alias: &ConsoleRuntimeIdentityAlias,
3520) -> Result<(), JsonRpcError> {
3521 let candidates = lookup_visible_member_alias_candidates_with_session(
3522 handle,
3523 visibility_policy,
3524 &alias.identity,
3525 )
3526 .await;
3527 if candidates.len() > 1 {
3528 return Err(ambiguous_live_identity_alias_error(
3529 &alias.identity,
3530 &candidates
3531 .iter()
3532 .map(|candidate| candidate.runtime_member_id.clone())
3533 .collect::<Vec<_>>(),
3534 ));
3535 }
3536 Ok(())
3537}
3538
3539fn ambiguous_live_identity_alias_error(
3540 requested_identity: &str,
3541 candidates: &[String],
3542) -> JsonRpcError {
3543 JsonRpcError {
3544 code: -32602,
3545 message: format!(
3546 "ambiguous live identity alias {requested_identity}: candidates [{}]",
3547 candidates.join(", ")
3548 ),
3549 data: Some(json!({
3550 "kind": "ambiguous_live_identity_alias",
3551 "identity": requested_identity,
3552 "candidates": candidates,
3553 })),
3554 }
3555}
3556
3557async fn lookup_member_runtime_alias_with_session(
3558 handle: &MobHandle,
3559 runtime_member_id: &str,
3560) -> Option<ConsoleRuntimeIdentityAlias> {
3561 let requested_member_id = crate::member_comms_id::mob_member_id(runtime_member_id);
3562 let entries = handle.list_members_including_retiring().await;
3563 let member = entries
3564 .into_iter()
3565 .find(|entry| entry.agent_identity == requested_member_id)?;
3566 let runtime_member_id =
3567 crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned();
3568 let identity = durable_identity_for_member(&member);
3569 let session_id = handle
3570 .resolve_bridge_session_id_observation(&member.agent_identity)
3571 .await
3572 .map(|s| s.to_string());
3573 Some(ConsoleRuntimeIdentityAlias {
3574 identity,
3575 runtime_member_id,
3576 member,
3577 session_id,
3578 })
3579}
3580
3581async fn identity_runtime_alias(
3582 identity_runtime: &crate::identity_first::IdentityRuntime,
3583 requested_identity: &str,
3584) -> Result<Option<(crate::identity_first::AgentIdentity, bool)>, String> {
3585 if requested_identity.starts_with("rt:") {
3586 for status in identity_runtime.statuses().await {
3587 if status
3588 .agent_runtime_id
3589 .as_ref()
3590 .is_some_and(|runtime_id| runtime_id.as_str() == requested_identity)
3591 {
3592 return Ok(Some((status.identity, false)));
3593 }
3594 }
3595 return Ok(None);
3596 }
3597 if let Ok(identity) = crate::identity_first::AgentIdentity::parse(requested_identity) {
3598 match identity_runtime.status(&identity).await {
3599 Ok(_) => return Ok(Some((identity, true))),
3600 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
3601 Err(err) => return Err(err.to_string()),
3602 }
3603 }
3604
3605 for status in identity_runtime.statuses().await {
3606 if status
3607 .agent_runtime_id
3608 .as_ref()
3609 .is_some_and(|runtime_id| runtime_id.as_str() == requested_identity)
3610 {
3611 return Ok(Some((status.identity, false)));
3612 }
3613 }
3614 Ok(None)
3615}
3616
3617async fn resolve_console_identity_control_target(
3618 handle: &MobHandle,
3619 identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
3620 visibility_policy: &dyn ConsoleVisibilityPolicy,
3621 requested_identity: &str,
3622) -> Result<
3623 Option<(
3624 crate::identity_first::AgentIdentity,
3625 bool,
3626 Option<ConsoleRuntimeIdentityAlias>,
3627 )>,
3628 JsonRpcError,
3629> {
3630 if let Some(identity_runtime) = identity_runtime {
3631 match identity_runtime_alias(identity_runtime, requested_identity).await {
3632 Ok(Some((identity, exact))) => {
3633 let live = if exact {
3634 let registered_live = match identity_runtime.status(&identity).await {
3635 Ok(status) => match status.agent_runtime_id.as_ref() {
3636 Some(runtime_id) => {
3637 lookup_member_runtime_alias_with_session(
3638 handle,
3639 runtime_id.as_str(),
3640 )
3641 .await
3642 }
3643 None => None,
3644 },
3645 Err(_) => None,
3646 };
3647 if let Some(alias) = registered_live.as_ref()
3648 && !runtime_alias_visible_to_console(handle, visibility_policy, alias)
3649 {
3650 return Err(identity_hidden_by_policy_error(requested_identity));
3651 }
3652 if let Some(registered) = registered_live {
3653 return Ok(Some((identity, exact, Some(registered))));
3654 }
3655 let requested_live_candidates =
3656 lookup_visible_member_alias_candidates_with_session(
3657 handle,
3658 visibility_policy,
3659 requested_identity,
3660 )
3661 .await;
3662 let requested_live = if requested_live_candidates.len() > 1 {
3663 return Err(ambiguous_live_identity_alias_error(
3664 requested_identity,
3665 &requested_live_candidates
3666 .iter()
3667 .map(|alias| alias.runtime_member_id.clone())
3668 .collect::<Vec<_>>(),
3669 ));
3670 } else {
3671 requested_live_candidates.into_iter().next()
3672 };
3673 match (registered_live, requested_live) {
3674 (Some(registered), Some(requested))
3675 if registered.runtime_member_id == requested.runtime_member_id =>
3676 {
3677 Some(registered)
3678 }
3679 (Some(registered), None) => Some(registered),
3680 (Some(_registered), Some(requested)) => Some(requested),
3681 (None, requested) => requested,
3682 }
3683 } else {
3684 let registered_live =
3685 lookup_member_runtime_alias_with_session(handle, requested_identity).await;
3686 if let Some(alias) = registered_live.as_ref()
3687 && !runtime_alias_visible_to_console(handle, visibility_policy, alias)
3688 {
3689 return Err(identity_hidden_by_policy_error(requested_identity));
3690 }
3691 if let Some(registered) = registered_live {
3692 return Ok(Some((identity, exact, Some(registered))));
3693 }
3694 let durable_live_candidates =
3695 lookup_visible_member_alias_candidates_with_session(
3696 handle,
3697 visibility_policy,
3698 identity.as_str(),
3699 )
3700 .await;
3701 let durable_live = if durable_live_candidates.len() > 1 {
3702 return Err(ambiguous_live_identity_alias_error(
3703 identity.as_str(),
3704 &durable_live_candidates
3705 .iter()
3706 .map(|alias| alias.runtime_member_id.clone())
3707 .collect::<Vec<_>>(),
3708 ));
3709 } else {
3710 durable_live_candidates.into_iter().next()
3711 };
3712 match (registered_live, durable_live) {
3713 (Some(registered), Some(durable))
3714 if registered.runtime_member_id == durable.runtime_member_id =>
3715 {
3716 Some(registered)
3717 }
3718 (Some(registered), None) => Some(registered),
3719 (Some(_registered), Some(durable)) => Some(durable),
3720 (None, durable) => durable,
3721 }
3722 };
3723 return Ok(Some((identity, exact, live)));
3724 }
3725 Ok(None) => {}
3726 Err(err) => {
3727 return Err(JsonRpcError {
3728 code: -32000,
3729 message: err,
3730 data: None,
3731 });
3732 }
3733 }
3734 }
3735
3736 let live_alias =
3737 lookup_member_alias_with_session(handle, visibility_policy, requested_identity).await?;
3738 let Some(alias) = live_alias else {
3739 return Ok(None);
3740 };
3741 if let Some(identity_runtime) = identity_runtime
3742 && let Some(bound_status) = identity_runtime
3743 .statuses()
3744 .await
3745 .into_iter()
3746 .find(|status| {
3747 status
3748 .agent_runtime_id
3749 .as_ref()
3750 .is_some_and(|runtime_id| runtime_id.as_str() == alias.runtime_member_id)
3751 })
3752 && bound_status.identity.as_str() != alias.identity
3753 {
3754 return Err(JsonRpcError {
3755 code: -32000,
3756 message: format!(
3757 "stale live identity alias: live console alias {} resolves to {}, but identity runtime binding belongs to {}",
3758 alias.identity,
3759 alias.runtime_member_id,
3760 bound_status.identity.as_str(),
3761 ),
3762 data: Some(json!({
3763 "kind": "stale_live_identity_alias",
3764 "identity": alias.identity,
3765 "runtime_member_id": alias.runtime_member_id,
3766 "registered_identity": bound_status.identity.as_str(),
3767 })),
3768 });
3769 }
3770 let identity = crate::identity_first::AgentIdentity::parse(&alias.identity).map_err(|err| {
3771 JsonRpcError {
3772 code: -32602,
3773 message: format!("invalid identity: {err}"),
3774 data: None,
3775 }
3776 })?;
3777 let durable_live_candidates = lookup_visible_member_alias_candidates_with_session(
3778 handle,
3779 visibility_policy,
3780 identity.as_str(),
3781 )
3782 .await;
3783 if durable_live_candidates.len() > 1 {
3784 return Err(ambiguous_live_identity_alias_error(
3785 identity.as_str(),
3786 &durable_live_candidates
3787 .iter()
3788 .map(|alias| alias.runtime_member_id.clone())
3789 .collect::<Vec<_>>(),
3790 ));
3791 }
3792 Ok(Some((identity, false, Some(alias))))
3793}
3794
3795fn live_alias_matches_status_runtime(
3796 alias: Option<&ConsoleRuntimeIdentityAlias>,
3797 status: &crate::identity_first::IdentityStatus,
3798) -> bool {
3799 let Some(alias) = alias else {
3800 return true;
3801 };
3802 let session_matches = match (
3803 status.session_id.as_ref().map(ToString::to_string),
3804 alias.session_id.as_deref(),
3805 ) {
3806 (Some(status_session), Some(live_session)) => status_session == live_session,
3807 _ => true,
3808 };
3809 status
3810 .agent_runtime_id
3811 .as_ref()
3812 .is_some_and(|runtime_id| runtime_id.as_str() == alias.runtime_member_id)
3813 && alias.identity == status.identity.as_str()
3814 && session_matches
3815}
3816
3817async fn stale_live_alias_json_rpc_error(
3818 operation: &str,
3819 identity_runtime: &crate::identity_first::IdentityRuntime,
3820 identity: &crate::identity_first::AgentIdentity,
3821 live_alias: Option<&ConsoleRuntimeIdentityAlias>,
3822) -> Option<JsonRpcError> {
3823 let live_alias = live_alias?;
3824 let Ok(status) = identity_runtime.status(identity).await else {
3825 return None;
3826 };
3827 if live_alias_matches_status_runtime(Some(live_alias), &status) {
3828 return None;
3829 }
3830 let registered_runtime_member_id = status
3831 .agent_runtime_id
3832 .as_ref()
3833 .map(crate::identity_first::AgentRuntimeId::as_str);
3834 Some(JsonRpcError {
3835 code: -32000,
3836 message: format!(
3837 "{operation} failed: identity runtime binding for {} points at {}, but requested live member is {}",
3838 identity.as_str(),
3839 registered_runtime_member_id.unwrap_or("<none>"),
3840 live_alias.runtime_member_id
3841 ),
3842 data: Some(json!({
3843 "kind": "stale_identity_runtime_binding",
3844 "identity": identity.as_str(),
3845 "registered_runtime_member_id": registered_runtime_member_id,
3846 "live_runtime_member_id": live_alias.runtime_member_id,
3847 "registered_session_id": status.session_id.as_ref().map(ToString::to_string),
3848 "live_session_id": live_alias.session_id,
3849 })),
3850 })
3851}
3852
3853fn identity_from_runtime_alias(alias: &str) -> Option<crate::identity_first::AgentIdentity> {
3854 let rest = alias.strip_prefix("rt:")?;
3855 let (identity, generation) = rest.rsplit_once(':')?;
3856 if identity.is_empty()
3857 || generation.is_empty()
3858 || !generation.chars().all(|ch| ch.is_ascii_digit())
3859 {
3860 return None;
3861 }
3862 crate::identity_first::AgentIdentity::parse(identity).ok()
3863}
3864
3865async fn stale_runtime_alias_json_rpc_error(
3866 operation: &str,
3867 identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
3868 alias: &str,
3869) -> Option<JsonRpcError> {
3870 let identity_runtime = identity_runtime?;
3871 let identity = identity_from_runtime_alias(alias)?;
3872 let Ok(status) = identity_runtime.status(&identity).await else {
3873 return None;
3874 };
3875 let registered_runtime_member_id = status
3876 .agent_runtime_id
3877 .as_ref()
3878 .map(crate::identity_first::AgentRuntimeId::as_str);
3879 if registered_runtime_member_id == Some(alias) {
3880 return None;
3881 }
3882 Some(JsonRpcError {
3883 code: -32000,
3884 message: format!(
3885 "{operation} failed: identity runtime binding for {} points at {}, but requested live member is {}",
3886 identity.as_str(),
3887 registered_runtime_member_id.unwrap_or("<none>"),
3888 alias
3889 ),
3890 data: Some(json!({
3891 "kind": "stale_identity_runtime_binding",
3892 "identity": identity.as_str(),
3893 "registered_runtime_member_id": registered_runtime_member_id,
3894 "live_runtime_member_id": alias,
3895 "registered_session_id": status.session_id.as_ref().map(ToString::to_string),
3896 "live_session_id": Value::Null,
3897 })),
3898 })
3899}
3900
3901fn reset_requires_session_bridge_json_rpc_error() -> JsonRpcError {
3902 JsonRpcError {
3903 code: -32602,
3904 message: "reset requires an identity runtime with a session bridge".to_string(),
3905 data: Some(json!({
3906 "kind": "identity_reset_requires_session_bridge",
3907 })),
3908 }
3909}
3910
3911fn console_identity_status_json_from_record(
3912 record: &crate::console_aggregator::ConsoleIdentityRecord,
3913 response_phase: Option<String>,
3914) -> Value {
3915 json!({
3916 "identity": record.identity,
3917 "state": record.health,
3918 "role": record.labels.get("role"),
3919 "addressability": if record.addressable { "addressable" } else { "internal_only" },
3920 "display_name": record.display_name,
3921 "labels": record.labels,
3922 "agent_runtime_id": record.runtime_member_id,
3923 "session_id": record.session_id,
3924 "generation": Value::Null,
3925 "checkpoint_version": Value::Null,
3926 "continuity_health": Value::Null,
3927 "lease_healthy": Value::Null,
3928 "lease": Value::Null,
3929 "response_phase": response_phase,
3930 })
3931}
3932
3933fn console_addressability_json(
3934 addressability: crate::identity_first::AgentAddressability,
3935) -> &'static str {
3936 match addressability {
3937 crate::identity_first::AgentAddressability::Addressable => "addressable",
3938 crate::identity_first::AgentAddressability::InternalOnly => "internal_only",
3939 }
3940}
3941
3942fn console_identity_record_from_identity_status(
3943 status: &crate::identity_first::IdentityStatus,
3944) -> ConsoleIdentityRecord {
3945 let mut labels = status.labels.clone();
3946 if let Some(profile) = status.profile.as_ref() {
3947 labels
3948 .entry("role".to_string())
3949 .or_insert_with(|| profile.as_str().to_string());
3950 }
3951 let runtime_member_id = status
3952 .agent_runtime_id
3953 .as_ref()
3954 .map(crate::identity_first::AgentRuntimeId::as_str)
3955 .unwrap_or_else(|| status.identity.as_str())
3956 .to_string();
3957 let addressable = status.addressability
3958 == crate::identity_first::AgentAddressability::Addressable
3959 && matches!(
3960 status.state,
3961 crate::identity_first::IdentityLifecycleState::Active
3962 | crate::identity_first::IdentityLifecycleState::Dormant
3963 | crate::identity_first::IdentityLifecycleState::Uninitialized
3964 );
3965 let visibility = match status.state {
3966 crate::identity_first::IdentityLifecycleState::Retiring => {
3967 ConsoleVisibility::RetiredReadable
3968 }
3969 crate::identity_first::IdentityLifecycleState::Broken
3970 | crate::identity_first::IdentityLifecycleState::Suspended => {
3971 ConsoleVisibility::Unreachable
3972 }
3973 _ if addressable => ConsoleVisibility::Addressable,
3974 _ => ConsoleVisibility::Hidden,
3975 };
3976 let health = match status.state {
3977 crate::identity_first::IdentityLifecycleState::Active => "ready",
3978 crate::identity_first::IdentityLifecycleState::Dormant => "dormant",
3979 crate::identity_first::IdentityLifecycleState::Uninitialized => "uninitialized",
3980 crate::identity_first::IdentityLifecycleState::Broken => "broken",
3981 crate::identity_first::IdentityLifecycleState::Suspended => "suspended",
3982 crate::identity_first::IdentityLifecycleState::Retiring => "retired",
3983 }
3984 .to_string();
3985 ConsoleIdentityRecord {
3986 identity: status.identity.as_str().to_string(),
3987 display_name: status
3988 .display_name
3989 .as_ref()
3990 .map(crate::identity_first::DisplayName::as_str)
3991 .unwrap_or_else(|| status.identity.as_str())
3992 .to_string(),
3993 runtime_key: "identity-first".to_string(),
3994 runtime_member_id,
3995 session_id: status.session_id.as_ref().map(ToString::to_string),
3996 visibility,
3997 addressable,
3998 health,
3999 topology_peers: Vec::new(),
4000 labels,
4001 }
4002}
4003
4004fn identity_hidden_by_policy_response(response_id: Value, identity: &str) -> Value {
4005 response_value(
4006 response_id,
4007 None,
4008 Some(identity_hidden_by_policy_error(identity)),
4009 )
4010}
4011
4012fn identity_hidden_by_policy_error(identity: &str) -> JsonRpcError {
4013 JsonRpcError {
4014 code: -32001,
4015 message: format!("unknown identity: {identity}"),
4016 data: Some(json!({
4017 "kind": "identity_hidden_by_policy",
4018 "identity": identity,
4019 })),
4020 }
4021}
4022
4023fn identity_status_visible_to_console(
4024 visibility_policy: &dyn ConsoleVisibilityPolicy,
4025 status: &crate::identity_first::IdentityStatus,
4026) -> bool {
4027 visibility_policy.identity_visible(&console_identity_record_from_identity_status(status))
4028}
4029
4030fn console_member_from_runtime_alias(
4031 handle: &MobHandle,
4032 alias: &ConsoleRuntimeIdentityAlias,
4033) -> ConsoleMember {
4034 ConsoleMember {
4035 agent_identity: alias.runtime_member_id.clone(),
4036 role: alias.member.role.to_string(),
4037 state: member_status_state_string(alias.member.status),
4038 model_capabilities: model_capabilities_for_member_entry(handle.definition(), &alias.member),
4039 runtime_mode: Some(alias.member.runtime_mode.to_string()),
4040 session_id: alias.session_id.clone(),
4041 wired_to: alias
4042 .member
4043 .wired_to
4044 .iter()
4045 .map(|peer| crate::member_comms_id::runtime_alias_str(peer.as_str()).into_owned())
4046 .collect(),
4047 labels: alias.member.labels.clone(),
4048 }
4049}
4050
4051fn console_identity_record_from_runtime_alias(
4052 alias: &ConsoleRuntimeIdentityAlias,
4053) -> ConsoleIdentityRecord {
4054 let addressable = alias
4055 .member
4056 .labels
4057 .get("addressable")
4058 .map(|value| !value.eq_ignore_ascii_case("false"))
4059 .unwrap_or(true)
4060 && alias.member.status == meerkat_mob::MobMemberStatus::Active;
4061 let visibility = match alias.member.status {
4062 meerkat_mob::MobMemberStatus::Retiring | meerkat_mob::MobMemberStatus::Completed => {
4063 ConsoleVisibility::RetiredReadable
4064 }
4065 meerkat_mob::MobMemberStatus::Broken | meerkat_mob::MobMemberStatus::Unknown => {
4068 ConsoleVisibility::Unreachable
4069 }
4070 _ if addressable => ConsoleVisibility::Addressable,
4071 _ => ConsoleVisibility::Hidden,
4072 };
4073 ConsoleIdentityRecord {
4074 identity: alias.identity.clone(),
4075 display_name: alias
4076 .member
4077 .labels
4078 .get("display_name")
4079 .cloned()
4080 .unwrap_or_else(|| alias.identity.clone()),
4081 runtime_key: "runtime".to_string(),
4082 runtime_member_id: alias.runtime_member_id.clone(),
4083 session_id: alias.session_id.clone(),
4084 visibility,
4085 addressable,
4086 health: match alias.member.status {
4087 meerkat_mob::MobMemberStatus::Active => "ready".to_string(),
4088 meerkat_mob::MobMemberStatus::Retiring => "retired".to_string(),
4089 other => format!("{other:?}").to_ascii_lowercase(),
4092 },
4093 topology_peers: alias
4094 .member
4095 .wired_to
4096 .iter()
4097 .map(ToString::to_string)
4098 .collect(),
4099 labels: alias.member.labels.clone(),
4100 }
4101}
4102
4103fn runtime_alias_visible_to_console(
4104 handle: &MobHandle,
4105 visibility_policy: &dyn ConsoleVisibilityPolicy,
4106 alias: &ConsoleRuntimeIdentityAlias,
4107) -> bool {
4108 let member = console_member_from_runtime_alias(handle, alias);
4109 if !visibility_policy.member_visible(&member) {
4110 return false;
4111 }
4112 visibility_policy.identity_visible(&console_identity_record_from_runtime_alias(alias))
4113}
4114
4115fn console_identity_status_json_from_identity_status(
4116 status: &crate::identity_first::IdentityStatus,
4117 response_phase: Option<String>,
4118) -> Value {
4119 json!({
4120 "identity": status.identity.as_str(),
4121 "state": status.state.wire_str(),
4122 "role": status.profile.as_ref().map(ProfileName::as_str),
4123 "addressability": console_addressability_json(status.addressability),
4124 "display_name": status.display_name.as_ref().map(crate::identity_first::DisplayName::as_str),
4125 "labels": status.labels,
4126 "agent_runtime_id": status.agent_runtime_id.as_ref().map(crate::identity_first::AgentRuntimeId::as_str),
4127 "session_id": status.session_id.as_ref().map(ToString::to_string),
4128 "generation": status.generation.map(crate::identity_first::ContinuityGeneration::get),
4129 "checkpoint_version": status.checkpoint_version.map(crate::identity_first::CheckpointVersion::get),
4130 "continuity_health": status.continuity_health,
4131 "lease_healthy": status.lease.as_ref().map(|lease| lease.healthy),
4132 "lease": status.lease.as_ref().map(|lease| json!({
4133 "fencing_token": lease.fencing_token.get(),
4134 "ttl_remaining_ms": lease.ttl_remaining.as_millis() as u64,
4135 "healthy": lease.healthy,
4136 })),
4137 "response_phase": response_phase,
4138 })
4139}
4140
4141fn console_identity_inspect_json_from_identity_status(
4142 status: &crate::identity_first::IdentityStatus,
4143 live_alias: Option<&ConsoleRuntimeIdentityAlias>,
4144 response_phase: Option<String>,
4145) -> Value {
4146 let topology_peers = live_alias
4147 .map(|alias| {
4148 alias
4149 .member
4150 .wired_to
4151 .iter()
4152 .map(ToString::to_string)
4153 .map(Value::String)
4154 .collect::<Vec<_>>()
4155 })
4156 .unwrap_or_default();
4157 let session_id = status
4158 .session_id
4159 .as_ref()
4160 .map(ToString::to_string)
4161 .or_else(|| live_alias.and_then(|alias| alias.session_id.clone()));
4162 let agent_runtime_id = status
4163 .agent_runtime_id
4164 .as_ref()
4165 .map(crate::identity_first::AgentRuntimeId::as_str)
4166 .map(ToString::to_string)
4167 .or_else(|| live_alias.map(|alias| alias.runtime_member_id.clone()));
4168 json!({
4169 "identity": status.identity.as_str(),
4170 "state": status.state.wire_str(),
4171 "role": status.profile.as_ref().map(ProfileName::as_str),
4172 "addressability": console_addressability_json(status.addressability),
4173 "display_name": status.display_name.as_ref().map(crate::identity_first::DisplayName::as_str),
4174 "labels": status.labels,
4175 "continuity_health": status.continuity_health,
4176 "lease_healthy": status.lease.as_ref().map(|lease| lease.healthy),
4177 "lease": status.lease.as_ref().map(|lease| json!({
4178 "fencing_token": lease.fencing_token.get(),
4179 "ttl_remaining_ms": lease.ttl_remaining.as_millis() as u64,
4180 "healthy": lease.healthy,
4181 })),
4182 "continuity": {
4183 "generation": status.generation.map(crate::identity_first::ContinuityGeneration::get),
4184 "checkpoint_version": status.checkpoint_version.map(crate::identity_first::CheckpointVersion::get),
4185 "session_id": session_id,
4186 "agent_runtime_id": agent_runtime_id,
4187 },
4188 "topology_peers": topology_peers,
4189 "output_preview": Value::Null,
4190 "response_phase": response_phase,
4191 })
4192}
4193
4194fn console_identity_inspect_json_from_record(
4195 inspection: &crate::console_aggregator::ConsoleIdentityInspection,
4196 response_phase: Option<String>,
4197) -> Value {
4198 let record = &inspection.identity;
4199 json!({
4200 "identity": record.identity,
4201 "state": record.health,
4202 "role": record.labels.get("role"),
4203 "addressability": if record.addressable { "addressable" } else { "internal_only" },
4204 "display_name": record.display_name,
4205 "labels": record.labels,
4206 "continuity_health": Value::Null,
4207 "lease_healthy": Value::Null,
4208 "lease": Value::Null,
4209 "continuity": {
4210 "generation": Value::Null,
4211 "checkpoint_version": Value::Null,
4212 "session_id": record.session_id,
4213 "agent_runtime_id": record.runtime_member_id,
4214 },
4215 "topology_peers": inspection.peers,
4216 "output_preview": Value::Null,
4217 "response_phase": response_phase,
4218 })
4219}
4220
4221fn lifecycle_archive_cleanup_completed(error: &str) -> bool {
4222 is_recoverable_lifecycle_cleanup_error(error)
4223}
4224
4225async fn respawn_console_member(
4226 handle: &MobHandle,
4227 runtime_member_id: &AgentIdentity,
4228) -> Result<Option<Value>, String> {
4229 let entry_before_respawn = handle.get_member(runtime_member_id).await.ok().flatten();
4232 match handle.respawn(runtime_member_id.clone(), None).await {
4233 Ok(_receipt) => Ok(None),
4234 Err(err) => {
4235 if let Some(failed_peer_ids) = topology_restore_failed_peer_ids(&err) {
4236 tracing::warn!(
4237 member_id = %runtime_member_id,
4238 failed_peer_count = failed_peer_ids.len(),
4239 failed_peer_ids = ?failed_peer_ids,
4240 "console member respawn restored member with isolated peer edges; continuing degraded respawn"
4241 );
4242 return Ok(Some(topology_restore_warning_json(&failed_peer_ids)));
4243 }
4244
4245 if lifecycle_archive_cleanup_completed(&err.to_string()) {
4246 if handle
4249 .get_member(runtime_member_id)
4250 .await
4251 .map_err(|lookup_err| lookup_err.to_string())?
4252 .is_none()
4253 && let Some(entry) = entry_before_respawn
4254 {
4255 let mut spec =
4256 SpawnMemberSpec::new(entry.role.clone(), runtime_member_id.clone());
4257 if !entry.labels.is_empty() {
4258 spec = spec.with_labels(entry.labels.clone());
4259 }
4260 handle
4261 .ensure_member(spec)
4262 .await
4263 .map_err(|ensure_err| ensure_err.to_string())?;
4264 }
4265 return Ok(None);
4266 }
4267
4268 Err(err.to_string())
4269 }
4270 }
4271}
4272
4273async fn respawn_live_console_member_response(
4277 handle: &MobHandle,
4278 console_events: Option<&ConsoleEventStore>,
4279 alias: &ConsoleRuntimeIdentityAlias,
4280 lifecycle_kind: &str,
4281 action: &str,
4282 response_id: Value,
4283) -> Value {
4284 let mid = crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str());
4285 match Box::pin(respawn_console_member(handle, &mid)).await {
4286 Ok(topology_restore_warning) => {
4287 if let Some(store) = console_events {
4288 store
4289 .record_lifecycle(
4290 &alias.identity,
4291 lifecycle_kind,
4292 json!({ "topology_restore_warning": topology_restore_warning.clone() }),
4293 )
4294 .await;
4295 }
4296 let mut body = match lookup_member_with_session(handle, &mid).await {
4297 Some((entry, session_id)) => console_identity_status_json_for_identity(
4298 &alias.identity,
4299 &entry,
4300 session_id,
4301 None,
4302 ),
4303 None => json!({ "identity": alias.identity }),
4304 };
4305 if let Some(warning) = topology_restore_warning {
4306 body["topology_restore_warning"] = warning;
4307 }
4308 response_value(response_id, Some(body), None)
4309 }
4310 Err(err) => internal_error(response_id, format!("{action} failed: {err}")),
4311 }
4312}
4313
4314async fn retire_console_member(
4315 handle: &MobHandle,
4316 runtime_member_id: &AgentIdentity,
4317) -> Result<(), String> {
4318 match handle.retire(runtime_member_id.clone()).await {
4319 Ok(()) => Ok(()),
4320 Err(err) if lifecycle_archive_cleanup_completed(&err.to_string()) => Ok(()),
4321 Err(err) => Err(err.to_string()),
4322 }
4323}
4324
4325#[cfg(test)]
4326fn member_id_matches_durable_identity(member_id: &str, durable_identity: &str) -> bool {
4327 member_id == durable_identity
4328}
4329
4330async fn retire_stale_console_members_for_identity(
4331 handle: &MobHandle,
4332 visibility_policy: &dyn ConsoleVisibilityPolicy,
4333 durable_identity: &str,
4334 keep_runtime_member_id: Option<&str>,
4335) -> Result<(), String> {
4336 let stale_members = lookup_member_alias_candidates_with_session(handle, durable_identity)
4337 .await
4338 .into_iter()
4339 .filter(|alias| {
4340 runtime_alias_visible_to_console(handle, visibility_policy, alias)
4341 && keep_runtime_member_id
4342 .map(|keep| alias.runtime_member_id != keep)
4343 .unwrap_or(true)
4344 })
4345 .map(|alias| crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str()))
4346 .collect::<Vec<_>>();
4347 for member_id in stale_members {
4348 retire_console_member(handle, &member_id).await?;
4349 }
4350 Ok(())
4351}
4352
4353fn console_identity_error_response(
4354 response_id: Value,
4355 operation: &str,
4356 err: crate::identity_first::IdentityRuntimeError,
4357) -> Value {
4358 match err {
4359 crate::identity_first::IdentityRuntimeError::UnknownIdentity(identity) => {
4360 invalid_params(response_id, format!("identity not found: {identity}"))
4361 }
4362 other => internal_error(response_id, format!("{operation} failed: {other}")),
4363 }
4364}
4365
4366#[allow(clippy::too_many_arguments)]
4367async fn handle_console_aggregator_rpc(
4368 console_aggregator: Option<MobKitConsoleAggregator>,
4369 request: JsonRpcRequest,
4370 is_authenticated: bool,
4371 read_only: bool,
4372 access: Option<&AccessController>,
4373 access_view: Option<&AccessView>,
4374) -> Value {
4375 let response_id = request.id.clone().unwrap_or(Value::Null);
4376 let can_mutate = is_authenticated && !read_only;
4377 if is_console_mutating_rpc_method(request.method.as_str()) && !can_mutate {
4378 return console_read_only_rpc_error(response_id);
4379 }
4380 if let Some(response) = handle_access_admin_rpc(access, access_view, &request) {
4381 return response;
4382 }
4383 if let Some(error) =
4384 console_rpc_access_violation(access_view, request.method.as_str(), &request.params)
4385 {
4386 return response_value(response_id, None, Some(error));
4387 }
4388 match request.method.as_str() {
4389 "mobkit/capabilities" => {
4390 let mut methods = vec![
4391 "mobkit/capabilities",
4392 "mobkit/console/list_identities",
4393 "mobkit/console/inspect_identity",
4394 "mobkit/console/query_timeline",
4395 ];
4396 if can_mutate {
4397 methods.extend_from_slice(&["mobkit/retire", "mobkit/console/send"]);
4398 }
4399 if access.is_some() {
4400 methods.push("mobkit/access/status");
4401 if access_view.is_some_and(AccessView::can_administer) {
4402 methods.extend_from_slice(&["mobkit/access/get", "mobkit/access/preview"]);
4403 if can_mutate {
4404 methods.extend_from_slice(&[
4405 "mobkit/access/set",
4406 "mobkit/access/enable",
4407 "mobkit/access/rules/upsert",
4408 "mobkit/access/rules/delete",
4409 "mobkit/access/groups/set",
4410 "mobkit/access/groups/delete",
4411 ]);
4412 }
4413 }
4414 }
4415 response_value(
4416 response_id,
4417 Some(json!({
4418 "methods": methods,
4419 "authenticated": is_authenticated,
4420 "read_only": read_only,
4421 "runtime_capabilities": {
4422 "can_send_messages": can_mutate,
4423 "can_retire_members": can_mutate,
4424 "can_spawn_members": false,
4425 },
4426 "features": {
4427 "console_aggregator": console_aggregator.is_some(),
4428 "multi_runtime_console": console_aggregator.is_some(),
4429 }
4430 })),
4431 None,
4432 )
4433 }
4434 "mobkit/console/list_identities" => {
4435 let Some(aggregator) = &console_aggregator else {
4436 return console_aggregator_unavailable(response_id);
4437 };
4438 match Box::pin(aggregator.list_identities()).await {
4439 Ok(mut identities) => {
4440 retain_visible_identity_records(&mut identities, access_view);
4441 response_value(response_id, Some(json!({ "identities": identities })), None)
4442 }
4443 Err(err) => internal_error(response_id, format!("list_identities failed: {err}")),
4444 }
4445 }
4446 "mobkit/console/inspect_identity" => {
4447 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
4448 return invalid_params(response_id, "identity required");
4449 };
4450 let Some(aggregator) = &console_aggregator else {
4451 return console_aggregator_unavailable(response_id);
4452 };
4453 match Box::pin(aggregator.inspect_identity(identity)).await {
4454 Ok(Some(inspection)) => response_value(
4455 response_id,
4456 Some(serde_json::to_value(inspection).unwrap_or(Value::Null)),
4457 None,
4458 ),
4459 Ok(None) => response_value(
4460 response_id,
4461 None,
4462 Some(JsonRpcError {
4463 code: -32001,
4464 message: format!("unknown identity: {identity}"),
4465 data: None,
4466 }),
4467 ),
4468 Err(err) => internal_error(response_id, format!("inspect_identity failed: {err}")),
4469 }
4470 }
4471 "mobkit/console/query_timeline" => {
4472 let query: ConsoleTimelineWindowQuery =
4473 match serde_json::from_value(request.params.clone()) {
4474 Ok(query) => query,
4475 Err(err) => {
4476 return invalid_params(response_id, format!("invalid query params: {err}"));
4477 }
4478 };
4479 let Some(aggregator) = &console_aggregator else {
4480 return console_aggregator_unavailable(response_id);
4481 };
4482 match Box::pin(aggregator.query_timeline_windowed(query.clone())).await {
4483 Ok(mut page) => {
4484 retain_visible_timeline_frames(&mut page, access_view);
4485 response_value(
4486 response_id,
4487 Some(serde_json::to_value(page).unwrap_or(Value::Null)),
4488 None,
4489 )
4490 }
4491 Err(err) => {
4492 let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
4493 console_timeline_replay_unavailable_response(
4494 response_id,
4495 err,
4496 query.after.as_ref(),
4497 latest_cursor,
4498 )
4499 }
4500 }
4501 }
4502 "mobkit/console/send" => {
4503 let send_request: ConsoleSendRequest =
4504 match serde_json::from_value(request.params.clone()) {
4505 Ok(request) => request,
4506 Err(err) => {
4507 return invalid_params(response_id, format!("invalid send params: {err}"));
4508 }
4509 };
4510 let Some(aggregator) = &console_aggregator else {
4511 return console_aggregator_unavailable(response_id);
4512 };
4513 match Box::pin(aggregator.send(send_request)).await {
4514 Ok(accepted) => response_value(
4515 response_id,
4516 Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
4517 None,
4518 ),
4519 Err(err) => {
4520 response_value(response_id, None, Some(console_send_json_rpc_error(err)))
4521 }
4522 }
4523 }
4524 "mobkit/retire" => {
4525 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
4526 return invalid_params(response_id, "identity required");
4527 };
4528 let Some(aggregator) = &console_aggregator else {
4529 return console_aggregator_unavailable(response_id);
4530 };
4531 match Box::pin(aggregator.retire_identity(identity)).await {
4532 Ok(true) => {
4533 response_value(response_id, Some(json!({ "identity": identity })), None)
4534 }
4535 Ok(false) => response_value(
4536 response_id,
4537 None,
4538 Some(JsonRpcError {
4539 code: -32001,
4540 message: format!("unknown identity: {identity}"),
4541 data: None,
4542 }),
4543 ),
4544 Err(err) => internal_error(response_id, format!("retire failed: {err}")),
4545 }
4546 }
4547 "mobkit/reset_all" => {
4548 let Some(_aggregator) = &console_aggregator else {
4549 return console_aggregator_unavailable(response_id);
4550 };
4551 response_value(
4552 response_id,
4553 None,
4554 Some(JsonRpcError {
4555 code: -32002,
4556 message: "reset_all is not supported on the aggregator-only RPC surface"
4557 .to_string(),
4558 data: Some(json!({
4559 "kind": "unsupported_reset_all_surface",
4560 "reason": "aggregator reset_all cannot preserve baseline identity semantics",
4561 })),
4562 }),
4563 )
4564 }
4565 _ => response_value(
4566 response_id,
4567 None,
4568 Some(JsonRpcError {
4569 code: -32601,
4570 message: "Method not found".to_string(),
4571 data: None,
4572 }),
4573 ),
4574 }
4575}
4576
4577fn console_aggregator_unavailable(response_id: Value) -> Value {
4578 response_value(
4579 response_id,
4580 None,
4581 Some(JsonRpcError {
4582 code: -32004,
4583 message: "console aggregator unavailable".to_string(),
4584 data: None,
4585 }),
4586 )
4587}
4588
4589fn resolve_gating_approver_id(
4590 params: &Value,
4591 authenticated_principal: Option<&str>,
4592) -> Result<String, &'static str> {
4593 if let Some(principal) = authenticated_principal.and_then(|value| {
4594 let trimmed = value.trim();
4595 (!trimmed.is_empty()).then_some(trimmed)
4596 }) {
4597 return Ok(principal.to_string());
4598 }
4599 let Some(approver_id) = params.get("approver_id").and_then(Value::as_str) else {
4600 return Err("approver_id required");
4601 };
4602 let trimmed = approver_id.trim();
4603 if trimmed.is_empty() {
4604 return Err("approver_id required");
4605 }
4606 Ok(trimmed.to_string())
4607}
4608
4609#[allow(clippy::large_futures, clippy::too_many_arguments)]
4610#[cfg(test)]
4611async fn handle_console_runtime_rpc(
4612 runtime: &MobRuntime,
4613 module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
4614 contact_directory: Option<&ContactDirectory>,
4615 gateway_peer_keys: Option<&crate::auth::peer_keys::GatewayPeerKeys>,
4616 console_events: Option<ConsoleEventStore>,
4617 console_aggregator: Option<MobKitConsoleAggregator>,
4618 identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
4619 metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
4620 mob_events: Option<MobEventsStore>,
4621 request: JsonRpcRequest,
4622 is_authenticated: bool,
4623) -> Value {
4624 handle_console_runtime_rpc_with_visibility(
4625 runtime,
4626 module_runtime,
4627 contact_directory,
4628 gateway_peer_keys,
4629 console_events,
4630 console_aggregator,
4631 identity_runtime,
4632 metadata_table,
4633 mob_events,
4634 &crate::console_aggregator::AllowAllConsoleVisibilityPolicy,
4635 request,
4636 is_authenticated,
4637 false,
4638 None,
4639 None,
4640 None,
4641 None,
4642 )
4643 .await
4644}
4645
4646#[allow(clippy::too_many_arguments)]
4647async fn handle_console_runtime_rpc_with_visibility(
4648 runtime: &MobRuntime,
4649 module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
4650 contact_directory: Option<&ContactDirectory>,
4651 gateway_peer_keys: Option<&crate::auth::peer_keys::GatewayPeerKeys>,
4652 console_events: Option<ConsoleEventStore>,
4653 console_aggregator: Option<MobKitConsoleAggregator>,
4654 identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
4655 metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
4656 mob_events: Option<MobEventsStore>,
4657 visibility_policy: &dyn ConsoleVisibilityPolicy,
4658 request: JsonRpcRequest,
4659 is_authenticated: bool,
4660 read_only: bool,
4661 authenticated_principal: Option<&str>,
4662 access: Option<&AccessController>,
4663 access_view: Option<&AccessView>,
4664 memory_panel: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
4665) -> Value {
4666 let response_id = request.id.clone().unwrap_or(Value::Null);
4667 let can_mutate = is_authenticated && !read_only;
4668 if is_console_mutating_rpc_method(request.method.as_str()) && !can_mutate {
4669 return console_read_only_rpc_error(response_id);
4670 }
4671 if let Some(controller) = access.filter(|controller| controller.enabled()) {
4677 prime_access_cache_from_runtime(runtime, controller).await;
4678 }
4679 if let Some(response) = handle_access_admin_rpc(access, access_view, &request) {
4680 return response;
4681 }
4682 if let Some(error) =
4683 console_rpc_access_violation(access_view, request.method.as_str(), &request.params)
4684 {
4685 return response_value(response_id, None, Some(error));
4686 }
4687
4688 match request.method.as_str() {
4689 "mobkit/capabilities" => {
4690 let mut methods = vec![
4691 "mobkit/status",
4692 "mobkit/capabilities",
4693 "mobkit/list_members",
4694 "mobkit/get_member",
4695 "mobkit/find_members",
4696 "mobkit/member_status",
4697 "mobkit/identity/resolved_tools",
4698 "mobkit/blob/get",
4699 "mobkit/wait_ready",
4700 "mobkit/flow_status",
4701 "mobkit/list_flows",
4702 "mobkit/list_runs",
4703 "mobkit/console/list_identities",
4704 "mobkit/console/inspect_identity",
4705 "mobkit/console/query_timeline",
4706 "mobkit/mob_events/query",
4707 "mobkit/mob_events/subscribe",
4708 "mobkit/cross_mob/peer_info",
4709 "mobkit/cross_mob/directory",
4710 "mobkit/peer_pubkey",
4711 ];
4712 if identity_runtime.is_some() {
4713 methods.extend_from_slice(&["mobkit/status_identity", "mobkit/inspect_identity"]);
4714 if let Some(identity_runtime) = &identity_runtime
4715 && identity_runtime.agent_memory_supports_recall().await
4716 {
4717 methods.push("mobkit/agent_memory/recall");
4718 if can_mutate && identity_runtime.agent_memory_supports_remember().await {
4719 methods.push("mobkit/agent_memory/remember");
4720 }
4721 if can_mutate && identity_runtime.agent_memory_supports_forget().await {
4722 methods.push("mobkit/agent_memory/forget");
4723 }
4724 if can_mutate && identity_runtime.agent_memory_supports_update().await {
4725 methods.push("mobkit/agent_memory/update");
4726 }
4727 if identity_runtime.agent_memory_supports_manifest().await {
4728 methods.push("mobkit/agent_memory/manifest");
4729 }
4730 }
4731 if can_mutate {
4732 methods.push("mobkit/delete_identity");
4733 }
4734 } else if console_aggregator.is_some() {
4735 methods.extend_from_slice(&["mobkit/status_identity", "mobkit/inspect_identity"]);
4736 }
4737 if module_runtime.is_some() {
4738 methods.extend_from_slice(&[
4739 "mobkit/routing/routes/list",
4740 "mobkit/delivery/history",
4741 "mobkit/gating/pending",
4742 "mobkit/gating/audit",
4743 ]);
4744 if can_mutate {
4745 methods.push("mobkit/gating/decide");
4746 }
4747 }
4748 if can_mutate {
4749 methods.extend_from_slice(&[
4750 "mobkit/retire",
4751 "mobkit/respawn",
4758 "mobkit/reset",
4759 "mobkit/reset_all",
4760 "mobkit/console/send",
4761 "mobkit/blob/upload",
4762 "mobkit/ensure_member",
4763 "mobkit/retire_member",
4764 "mobkit/respawn_member",
4765 "mobkit/force_cancel_member",
4766 "mobkit/cancel_flow",
4767 "mobkit/collect_completed",
4768 "mobkit/run_flow",
4769 "mobkit/spawn_helper",
4770 "mobkit/fork_helper",
4771 "mobkit/attach_existing_session",
4772 "mobkit/reconcile_edges",
4773 "mobkit/cross_mob/wire_local",
4774 "mobkit/cross_mob/unwire_local",
4775 ]);
4776 }
4777 if metadata_table.is_some() {
4778 methods.extend_from_slice(&["mobkit/mob_labels/get", "mobkit/run_labels/get"]);
4779 if can_mutate {
4780 methods.extend_from_slice(&[
4781 "mobkit/mob_labels/set",
4782 "mobkit/mob_labels/delete",
4783 "mobkit/run_labels/set",
4784 "mobkit/run_labels/delete",
4785 ]);
4786 }
4787 }
4788 if memory_panel.is_some() {
4789 methods.extend_from_slice(&[
4795 "mobkit/memory/panel/records",
4796 "mobkit/memory/panel/record",
4797 "mobkit/memory/panel/dreams",
4798 "mobkit/memory/panel/quarantine",
4799 ]);
4800 }
4801 if access.is_some() {
4802 methods.push("mobkit/access/status");
4803 if access_view.is_some_and(AccessView::can_administer) {
4804 methods.extend_from_slice(&["mobkit/access/get", "mobkit/access/preview"]);
4805 if can_mutate {
4806 methods.extend_from_slice(&[
4807 "mobkit/access/set",
4808 "mobkit/access/enable",
4809 "mobkit/access/rules/upsert",
4810 "mobkit/access/rules/delete",
4811 "mobkit/access/groups/set",
4812 "mobkit/access/groups/delete",
4813 ]);
4814 }
4815 }
4816 }
4817 if let Some(view) = access_view.filter(|view| view.enforced()) {
4825 let probe = serde_json::json!({ "identity": "\u{0}cap-probe" });
4826 methods.retain(
4827 |method| match console_rpc_access_requirements(method, &probe) {
4828 Some(requirements) => requirements
4829 .iter()
4830 .all(|(action, target)| target.is_some() || view.allows(action)),
4831 None => true,
4832 },
4833 );
4834 }
4835 let cap = |action: &str| -> bool {
4838 can_mutate
4839 && access_view
4840 .filter(|view| view.enforced())
4841 .is_none_or(|view| view.may_perform_anywhere(action))
4842 };
4843 response_value(
4844 response_id,
4845 Some(serde_json::json!({
4846 "contract_version": crate::rpc::MOBKIT_CONTRACT_VERSION,
4847 "methods": methods,
4848 "read_only": read_only,
4849 "loaded_modules": serde_json::json!([]),
4852 "runtime_capabilities": {
4853 "can_send_messages": cap(ACTION_AGENT_SEND),
4854 "can_retire_members": cap(ACTION_AGENT_RETIRE),
4855 "can_spawn_members": cap(ACTION_AGENT_SPAWN),
4856 }
4857 })),
4858 None,
4859 )
4860 }
4861 "mobkit/agent_memory/remember" => {
4862 let Some(identity_runtime) = &identity_runtime else {
4863 return response_value(
4864 response_id,
4865 None,
4866 Some(JsonRpcError {
4867 code: -32601,
4868 message: "agent memory is not configured".to_string(),
4869 data: None,
4870 }),
4871 );
4872 };
4873 match parse_agent_memory_remember_params(&request.params) {
4874 Ok(remember_request) => match identity_runtime
4875 .remember_agent_memory(
4876 &remember_request.realm,
4877 &remember_request.identity,
4878 remember_request.memory,
4879 )
4880 .await
4881 {
4882 Ok(record) => response_value(
4883 response_id,
4884 Some(serde_json::to_value(record).unwrap_or(Value::Null)),
4885 None,
4886 ),
4887 Err(err) => response_value(
4888 response_id,
4889 None,
4890 Some(crate::rpc::agent_memory_rpc_error("write", err)),
4891 ),
4892 },
4893 Err(err) => {
4894 invalid_params(response_id, format!("Invalid params: {}", err.message()))
4895 }
4896 }
4897 }
4898 "mobkit/agent_memory/forget" => {
4899 let Some(identity_runtime) = &identity_runtime else {
4900 return response_value(
4901 response_id,
4902 None,
4903 Some(JsonRpcError {
4904 code: -32601,
4905 message: "agent memory is not configured".to_string(),
4906 data: None,
4907 }),
4908 );
4909 };
4910 match parse_agent_memory_forget_params(&request.params) {
4911 Ok(forget_request) => match identity_runtime
4912 .forget_agent_memory(
4913 &forget_request.realm,
4914 &forget_request.identity,
4915 &forget_request.memory_id,
4916 )
4917 .await
4918 {
4919 Ok(result) => response_value(
4920 response_id,
4921 Some(serde_json::to_value(result).unwrap_or(Value::Null)),
4922 None,
4923 ),
4924 Err(err) => response_value(
4925 response_id,
4926 None,
4927 Some(crate::rpc::agent_memory_rpc_error("forget", err)),
4928 ),
4929 },
4930 Err(err) => {
4931 invalid_params(response_id, format!("Invalid params: {}", err.message()))
4932 }
4933 }
4934 }
4935 "mobkit/agent_memory/recall" => {
4936 let Some(identity_runtime) = &identity_runtime else {
4937 return response_value(
4938 response_id,
4939 None,
4940 Some(JsonRpcError {
4941 code: -32601,
4942 message: "agent memory is not configured".to_string(),
4943 data: None,
4944 }),
4945 );
4946 };
4947 match parse_agent_memory_recall_params(&request.params) {
4948 Ok(recall_request) => match identity_runtime
4949 .recall_agent_memory(recall_request.request)
4950 .await
4951 {
4952 Ok(records) => {
4953 response_value(response_id, Some(json!({ "records": records })), None)
4954 }
4955 Err(err) => response_value(
4956 response_id,
4957 None,
4958 Some(crate::rpc::agent_memory_rpc_error("recall", err)),
4959 ),
4960 },
4961 Err(err) => {
4962 invalid_params(response_id, format!("Invalid params: {}", err.message()))
4963 }
4964 }
4965 }
4966 "mobkit/agent_memory/update" => {
4967 let Some(identity_runtime) = &identity_runtime else {
4968 return response_value(
4969 response_id,
4970 None,
4971 Some(JsonRpcError {
4972 code: -32601,
4973 message: "agent memory is not configured".to_string(),
4974 data: None,
4975 }),
4976 );
4977 };
4978 match parse_agent_memory_update_params(&request.params) {
4979 Ok(update_request) => match identity_runtime
4980 .update_agent_memory(
4981 &update_request.realm,
4982 &update_request.identity,
4983 &update_request.memory_id,
4984 update_request.memory,
4985 )
4986 .await
4987 {
4988 Ok(new_id) => response_value(
4989 response_id,
4990 Some(json!({
4991 "memory_id": new_id,
4992 "supersedes": update_request.memory_id,
4993 })),
4994 None,
4995 ),
4996 Err(err) => response_value(
4997 response_id,
4998 None,
4999 Some(crate::rpc::agent_memory_rpc_error("update", err)),
5000 ),
5001 },
5002 Err(err) => {
5003 invalid_params(response_id, format!("Invalid params: {}", err.message()))
5004 }
5005 }
5006 }
5007 "mobkit/agent_memory/manifest" => {
5008 let Some(identity_runtime) = &identity_runtime else {
5009 return response_value(
5010 response_id,
5011 None,
5012 Some(JsonRpcError {
5013 code: -32601,
5014 message: "agent memory is not configured".to_string(),
5015 data: None,
5016 }),
5017 );
5018 };
5019 match parse_agent_memory_manifest_params(&request.params) {
5020 Ok(manifest_request) => match identity_runtime
5021 .manifest_agent_memory(
5022 &manifest_request.realm,
5023 &manifest_request.identity,
5024 manifest_request.tier,
5025 )
5026 .await
5027 {
5028 Ok(records) => {
5029 response_value(response_id, Some(json!({ "records": records })), None)
5030 }
5031 Err(err) => response_value(
5032 response_id,
5033 None,
5034 Some(crate::rpc::agent_memory_rpc_error("manifest", err)),
5035 ),
5036 },
5037 Err(err) => {
5038 invalid_params(response_id, format!("Invalid params: {}", err.message()))
5039 }
5040 }
5041 }
5042 "mobkit/memory/panel/records" => {
5047 handle_memory_panel_records(memory_panel, access_view, &request.params, response_id)
5048 .await
5049 }
5050 "mobkit/memory/panel/record" => {
5051 handle_memory_panel_record(memory_panel, access_view, &request.params, response_id)
5052 .await
5053 }
5054 "mobkit/memory/panel/quarantine" => {
5055 handle_memory_panel_quarantine(memory_panel, access_view, &request.params, response_id)
5056 .await
5057 }
5058 "mobkit/memory/panel/dreams" => {
5059 handle_memory_panel_dreams(memory_panel, &request.params, response_id).await
5060 }
5061 "mobkit/status" => {
5062 let mob_state = runtime.handle().status_observation_snapshot();
5063 response_value(
5064 response_id,
5065 Some(serde_json::json!({
5066 "contract_version": crate::rpc::MOBKIT_CONTRACT_VERSION,
5067 "running": matches!(mob_state, MobState::Creating | MobState::Running),
5068 "loaded_modules": serde_json::json!([]),
5071 })),
5072 None,
5073 )
5074 }
5075 "mobkit/console/list_identities" => {
5076 let Some(aggregator) = &console_aggregator else {
5077 return response_value(
5078 response_id,
5079 None,
5080 Some(JsonRpcError {
5081 code: -32004,
5082 message: "console aggregator unavailable".to_string(),
5083 data: None,
5084 }),
5085 );
5086 };
5087 match Box::pin(aggregator.list_identities()).await {
5088 Ok(mut identities) => {
5089 retain_visible_identity_records(&mut identities, access_view);
5090 response_value(response_id, Some(json!({ "identities": identities })), None)
5091 }
5092 Err(err) => internal_error(response_id, format!("list_identities failed: {err}")),
5093 }
5094 }
5095 "mobkit/console/inspect_identity" => {
5096 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5097 return invalid_params(response_id, "identity required");
5098 };
5099 let Some(aggregator) = &console_aggregator else {
5100 return response_value(
5101 response_id,
5102 None,
5103 Some(JsonRpcError {
5104 code: -32004,
5105 message: "console aggregator unavailable".to_string(),
5106 data: None,
5107 }),
5108 );
5109 };
5110 match Box::pin(aggregator.inspect_identity(identity)).await {
5111 Ok(Some(inspection)) => response_value(
5112 response_id,
5113 Some(serde_json::to_value(inspection).unwrap_or(Value::Null)),
5114 None,
5115 ),
5116 Ok(None) => response_value(
5117 response_id,
5118 None,
5119 Some(JsonRpcError {
5120 code: -32001,
5121 message: format!("unknown identity: {identity}"),
5122 data: None,
5123 }),
5124 ),
5125 Err(err) => internal_error(response_id, format!("inspect_identity failed: {err}")),
5126 }
5127 }
5128 "mobkit/console/query_timeline" => {
5129 let query: ConsoleTimelineWindowQuery =
5130 match serde_json::from_value(request.params.clone()) {
5131 Ok(query) => query,
5132 Err(err) => {
5133 return invalid_params(response_id, format!("invalid query params: {err}"));
5134 }
5135 };
5136 let Some(aggregator) = &console_aggregator else {
5137 return response_value(
5138 response_id,
5139 None,
5140 Some(JsonRpcError {
5141 code: -32004,
5142 message: "console aggregator unavailable".to_string(),
5143 data: None,
5144 }),
5145 );
5146 };
5147 match Box::pin(aggregator.query_timeline_windowed(query.clone())).await {
5148 Ok(mut page) => {
5149 retain_visible_timeline_frames(&mut page, access_view);
5150 response_value(
5151 response_id,
5152 Some(serde_json::to_value(page).unwrap_or(Value::Null)),
5153 None,
5154 )
5155 }
5156 Err(err) => {
5157 let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
5158 console_timeline_replay_unavailable_response(
5159 response_id,
5160 err,
5161 query.after.as_ref(),
5162 latest_cursor,
5163 )
5164 }
5165 }
5166 }
5167 "mobkit/console/send" => {
5168 let send_request: ConsoleSendRequest =
5169 match serde_json::from_value(request.params.clone()) {
5170 Ok(request) => request,
5171 Err(err) => {
5172 return invalid_params(response_id, format!("invalid send params: {err}"));
5173 }
5174 };
5175 let Some(aggregator) = &console_aggregator else {
5176 return response_value(
5177 response_id,
5178 None,
5179 Some(JsonRpcError {
5180 code: -32004,
5181 message: "console aggregator unavailable".to_string(),
5182 data: None,
5183 }),
5184 );
5185 };
5186 if let Some(identity_runtime) = &identity_runtime {
5187 return match Box::pin(console_send_with_identity_first_fallback(
5188 aggregator,
5189 identity_runtime.clone(),
5190 Some(runtime),
5191 console_events.as_ref(),
5192 send_request,
5193 ))
5194 .await
5195 {
5196 Ok(accepted) => response_value(
5197 response_id,
5198 Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
5199 None,
5200 ),
5201 Err(err) => {
5202 response_value(response_id, None, Some(console_send_json_rpc_error(err)))
5203 }
5204 };
5205 }
5206 match Box::pin(aggregator.send(send_request)).await {
5207 Ok(accepted) => response_value(
5208 response_id,
5209 Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
5210 None,
5211 ),
5212 Err(err) => {
5213 response_value(response_id, None, Some(console_send_json_rpc_error(err)))
5214 }
5215 }
5216 }
5217 "mobkit/blob/get" => {
5218 let Some(blob_id) = request
5219 .params
5220 .get("blob_id")
5221 .or_else(|| request.params.get("id"))
5222 .and_then(Value::as_str)
5223 else {
5224 return invalid_params(response_id, "blob_id required");
5225 };
5226 if !is_valid_blob_id_value(blob_id) {
5227 return invalid_params(response_id, "invalid blob_id");
5228 }
5229 let Some(store) = runtime.binary_blob_store() else {
5230 return internal_error(response_id, "binary blob store unavailable");
5231 };
5232 match store.get_bytes(&meerkat_core::BlobId::from(blob_id)).await {
5233 Ok(payload) => response_value(
5234 response_id,
5235 Some(serde_json::json!({
5236 "blob_id": payload.blob_id,
5237 "media_type": payload.media_type,
5238 "size": payload.size,
5239 "data": base64::engine::general_purpose::STANDARD.encode(payload.data.as_ref()),
5240 })),
5241 None,
5242 ),
5243 Err(meerkat_core::BlobStoreError::NotFound(_)) => response_value(
5244 response_id,
5245 None,
5246 Some(JsonRpcError {
5247 code: -32001,
5248 message: format!("blob not found: {blob_id}"),
5249 data: Some(json!({ "kind": "not_found", "blob_id": blob_id })),
5250 }),
5251 ),
5252 Err(err) => internal_error(response_id, format!("blob get failed: {err}")),
5253 }
5254 }
5255 "mobkit/list_members" => {
5256 let handle = runtime.handle();
5257 let entries = handle.list_members_including_retiring().await;
5258 let mut members = Vec::with_capacity(entries.len());
5259 for entry in &entries {
5260 let alias =
5261 crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str());
5262 if stale_runtime_alias_json_rpc_error(
5263 "list_members",
5264 identity_runtime.as_ref(),
5265 alias.as_ref(),
5266 )
5267 .await
5268 .is_some()
5269 {
5270 continue;
5271 }
5272 members.push(member_entry_to_console_json(runtime, entry).await);
5273 }
5274 retain_visible_member_rows(&mut members, access_view);
5275 response_value(response_id, Some(Value::Array(members)), None)
5276 }
5277 "mobkit/get_member" => {
5278 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
5279 return invalid_params(response_id, "member_id required");
5280 };
5281 if let Some(error) = stale_runtime_alias_json_rpc_error(
5282 "get_member",
5283 identity_runtime.as_ref(),
5284 member_id,
5285 )
5286 .await
5287 {
5288 return response_value(response_id, None, Some(error));
5289 }
5290 let handle = runtime.handle();
5291 let identity = crate::member_comms_id::mob_member_id(member_id);
5292 let entries = handle.list_members_including_retiring().await;
5293 match entries.into_iter().find(|e| e.agent_identity == identity) {
5294 Some(entry) => response_value(
5295 response_id,
5296 Some(member_entry_to_console_json(runtime, &entry).await),
5297 None,
5298 ),
5299 None => invalid_params(response_id, format!("member not found: {member_id}")),
5300 }
5301 }
5302 "mobkit/find_members" => {
5303 let Some(label_key) = request.params.get("label_key").and_then(Value::as_str) else {
5304 return invalid_params(response_id, "label_key required");
5305 };
5306 let Some(label_value) = request.params.get("label_value").and_then(Value::as_str)
5307 else {
5308 return invalid_params(response_id, "label_value required");
5309 };
5310 let handle = runtime.handle();
5311 let filter = MemberFilter {
5312 labels: std::collections::BTreeMap::from([(
5313 label_key.to_string(),
5314 label_value.to_string(),
5315 )]),
5316 role: None,
5317 status: None,
5318 };
5319 let entries = match handle.list_members_matching(filter).await {
5320 Ok(entries) => entries,
5321 Err(err) => {
5322 return invalid_params(response_id, format!("member lookup failed: {err}"));
5323 }
5324 };
5325 let mut matches = Vec::with_capacity(entries.len());
5326 for entry in &entries {
5327 let alias =
5328 crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str());
5329 if stale_runtime_alias_json_rpc_error(
5330 "find_members",
5331 identity_runtime.as_ref(),
5332 alias.as_ref(),
5333 )
5334 .await
5335 .is_some()
5336 {
5337 continue;
5338 }
5339 matches.push(member_entry_to_console_json(runtime, entry).await);
5340 }
5341 retain_visible_member_rows(&mut matches, access_view);
5342 response_value(response_id, Some(Value::Array(matches)), None)
5343 }
5344 "mobkit/status_identity" => {
5345 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5346 return invalid_params(response_id, "identity required");
5347 };
5348 let handle = runtime.handle();
5349 if let Some(identity_runtime) = &identity_runtime {
5350 let (parsed_identity, _requested_exact_identity, live_alias) =
5351 match resolve_console_identity_control_target(
5352 &handle,
5353 Some(identity_runtime),
5354 visibility_policy,
5355 identity,
5356 )
5357 .await
5358 {
5359 Ok(Some(target)) => target,
5360 Ok(None) => {
5361 return invalid_params(
5362 response_id,
5363 format!("identity not found: {identity}"),
5364 );
5365 }
5366 Err(err) => return response_value(response_id, None, Some(err)),
5367 };
5368 match identity_runtime.status(&parsed_identity).await {
5369 Ok(status) => {
5370 if !identity_status_visible_to_console(visibility_policy, &status) {
5371 return identity_hidden_by_policy_response(response_id, identity);
5372 }
5373 let phase = if let Some(store) = &console_events {
5374 store
5375 .response_phase_for_identity(status.identity.as_str())
5376 .await
5377 } else {
5378 None
5379 };
5380 if let Some(error) = stale_live_alias_json_rpc_error(
5381 "status_identity",
5382 identity_runtime,
5383 &parsed_identity,
5384 live_alias.as_ref(),
5385 )
5386 .await
5387 {
5388 return response_value(response_id, None, Some(error));
5389 }
5390 return response_value(
5391 response_id,
5392 Some(console_identity_status_json_from_identity_status(
5393 &status, phase,
5394 )),
5395 None,
5396 );
5397 }
5398 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
5399 Err(err) => {
5400 return console_identity_error_response(
5401 response_id,
5402 "status_identity",
5403 err,
5404 );
5405 }
5406 }
5407 }
5408 if let Some(aggregator) = &console_aggregator {
5409 return match Box::pin(aggregator.inspect_identity(identity)).await {
5410 Ok(Some(inspection)) => {
5411 let phase = if let Some(store) = &console_events {
5412 store
5413 .response_phase_for_identity(&inspection.identity.identity)
5414 .await
5415 } else {
5416 None
5417 };
5418 response_value(
5419 response_id,
5420 Some(console_identity_status_json_from_record(
5421 &inspection.identity,
5422 phase,
5423 )),
5424 None,
5425 )
5426 }
5427 Ok(None) => response_value(
5428 response_id,
5429 None,
5430 Some(JsonRpcError {
5431 code: -32001,
5432 message: format!("unknown identity: {identity}"),
5433 data: None,
5434 }),
5435 ),
5436 Err(err) => {
5437 internal_error(response_id, format!("status_identity failed: {err}"))
5438 }
5439 };
5440 }
5441 let live_alias = match lookup_member_alias_with_session(
5442 &handle,
5443 visibility_policy,
5444 identity,
5445 )
5446 .await
5447 {
5448 Ok(alias) => alias,
5449 Err(err) => return response_value(response_id, None, Some(err)),
5450 };
5451 let Some(alias) = live_alias else {
5452 return invalid_params(response_id, format!("identity not found: {identity}"));
5453 };
5454 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
5455 return identity_hidden_by_policy_response(response_id, identity);
5456 }
5457 if let Err(err) =
5458 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
5459 {
5460 return response_value(response_id, None, Some(err));
5461 }
5462 let phase = if let Some(store) = &console_events {
5463 store.response_phase_for_identity(&alias.identity).await
5464 } else {
5465 None
5466 };
5467 response_value(
5468 response_id,
5469 Some(console_identity_status_json_for_identity(
5470 &alias.identity,
5471 &alias.member,
5472 alias.session_id,
5473 phase,
5474 )),
5475 None,
5476 )
5477 }
5478 "mobkit/inspect_identity" => {
5479 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5480 return invalid_params(response_id, "identity required");
5481 };
5482 let handle = runtime.handle();
5483 if let Some(identity_runtime) = &identity_runtime {
5484 let (parsed_identity, _requested_exact_identity, live_alias) =
5485 match resolve_console_identity_control_target(
5486 &handle,
5487 Some(identity_runtime),
5488 visibility_policy,
5489 identity,
5490 )
5491 .await
5492 {
5493 Ok(Some(target)) => target,
5494 Ok(None) => {
5495 return invalid_params(
5496 response_id,
5497 format!("identity not found: {identity}"),
5498 );
5499 }
5500 Err(err) => return response_value(response_id, None, Some(err)),
5501 };
5502 match identity_runtime.status(&parsed_identity).await {
5503 Ok(status) => {
5504 if !identity_status_visible_to_console(visibility_policy, &status) {
5505 return identity_hidden_by_policy_response(response_id, identity);
5506 }
5507 let phase = if let Some(store) = &console_events {
5508 store
5509 .response_phase_for_identity(status.identity.as_str())
5510 .await
5511 } else {
5512 None
5513 };
5514 if let Some(error) = stale_live_alias_json_rpc_error(
5515 "inspect_identity",
5516 identity_runtime,
5517 &parsed_identity,
5518 live_alias.as_ref(),
5519 )
5520 .await
5521 {
5522 return response_value(response_id, None, Some(error));
5523 }
5524 return response_value(
5525 response_id,
5526 Some(console_identity_inspect_json_from_identity_status(
5527 &status,
5528 live_alias.as_ref(),
5529 phase,
5530 )),
5531 None,
5532 );
5533 }
5534 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
5535 Err(err) => {
5536 return console_identity_error_response(
5537 response_id,
5538 "inspect_identity",
5539 err,
5540 );
5541 }
5542 }
5543 }
5544 if let Some(aggregator) = &console_aggregator {
5545 return match Box::pin(aggregator.inspect_identity(identity)).await {
5546 Ok(Some(inspection)) => {
5547 let phase = if let Some(store) = &console_events {
5548 store
5549 .response_phase_for_identity(&inspection.identity.identity)
5550 .await
5551 } else {
5552 None
5553 };
5554 response_value(
5555 response_id,
5556 Some(console_identity_inspect_json_from_record(
5557 &inspection,
5558 phase,
5559 )),
5560 None,
5561 )
5562 }
5563 Ok(None) => response_value(
5564 response_id,
5565 None,
5566 Some(JsonRpcError {
5567 code: -32001,
5568 message: format!("unknown identity: {identity}"),
5569 data: None,
5570 }),
5571 ),
5572 Err(err) => {
5573 internal_error(response_id, format!("inspect_identity failed: {err}"))
5574 }
5575 };
5576 }
5577 let live_alias = match lookup_member_alias_with_session(
5578 &handle,
5579 visibility_policy,
5580 identity,
5581 )
5582 .await
5583 {
5584 Ok(alias) => alias,
5585 Err(err) => return response_value(response_id, None, Some(err)),
5586 };
5587 let Some(alias) = live_alias else {
5588 return invalid_params(response_id, format!("identity not found: {identity}"));
5589 };
5590 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
5591 return identity_hidden_by_policy_response(response_id, identity);
5592 }
5593 if let Err(err) =
5594 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
5595 {
5596 return response_value(response_id, None, Some(err));
5597 }
5598 let phase = if let Some(store) = &console_events {
5599 store.response_phase_for_identity(&alias.identity).await
5600 } else {
5601 None
5602 };
5603 response_value(
5604 response_id,
5605 Some(console_identity_inspect_json_for_identity(
5606 &alias.identity,
5607 &alias.member,
5608 alias.session_id,
5609 phase,
5610 )),
5611 None,
5612 )
5613 }
5614 "mobkit/retire" => {
5615 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5616 return invalid_params(response_id, "identity required");
5617 };
5618 let handle = runtime.handle();
5619 if let Some(identity_runtime) = &identity_runtime {
5620 let (parsed_identity, _requested_exact_identity, live_alias) =
5621 match resolve_console_identity_control_target(
5622 &handle,
5623 Some(identity_runtime),
5624 visibility_policy,
5625 identity,
5626 )
5627 .await
5628 {
5629 Ok(Some(target)) => target,
5630 Ok(None) => {
5631 return invalid_params(
5632 response_id,
5633 format!("identity not found: {identity}"),
5634 );
5635 }
5636 Err(err) => return response_value(response_id, None, Some(err)),
5637 };
5638 let registered_status = match identity_runtime.status(&parsed_identity).await {
5639 Ok(status) => Some(status),
5640 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => None,
5641 Err(err) => {
5642 return console_identity_error_response(response_id, "retire", err);
5643 }
5644 };
5645 if let Some(status) = registered_status.as_ref() {
5646 if !identity_status_visible_to_console(visibility_policy, status) {
5647 return identity_hidden_by_policy_response(response_id, identity);
5648 }
5649 if let Some(error) = stale_live_alias_json_rpc_error(
5650 "retire",
5651 identity_runtime,
5652 &parsed_identity,
5653 live_alias.as_ref(),
5654 )
5655 .await
5656 {
5657 return response_value(response_id, None, Some(error));
5658 }
5659 }
5660 match identity_runtime.retire(&parsed_identity).await {
5661 Ok(token) => {
5662 let keep_runtime_member_id = registered_status
5663 .as_ref()
5664 .and_then(|status| status.agent_runtime_id.as_ref())
5665 .filter(|_| identity_runtime.has_session_bridge())
5666 .map(crate::identity_first::AgentRuntimeId::as_str);
5667 let cleanup_warning = if registered_status.is_some()
5668 && let Err(err) = retire_stale_console_members_for_identity(
5669 &handle,
5670 visibility_policy,
5671 parsed_identity.as_str(),
5672 keep_runtime_member_id,
5673 )
5674 .await
5675 {
5676 Some(json!({
5677 "kind": "stale_member_cleanup_failed_after_identity_retire",
5678 "identity": parsed_identity.as_str(),
5679 "message": err,
5680 }))
5681 } else {
5682 None
5683 };
5684 if let Some(store) = &console_events {
5685 store
5686 .record_lifecycle(
5687 parsed_identity.as_str(),
5688 "identity_retired",
5689 json!({
5690 "fencing_token": token.get(),
5691 "cleanup_warning": cleanup_warning.clone(),
5692 }),
5693 )
5694 .await;
5695 }
5696 return response_value(
5697 response_id,
5698 Some(json!({
5699 "identity": parsed_identity.as_str(),
5700 "fencing_token": token.get(),
5701 "cleanup_warning": cleanup_warning,
5702 })),
5703 None,
5704 );
5705 }
5706 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
5707 if let Some(alias) = live_alias.as_ref() {
5708 if !runtime_alias_visible_to_console(&handle, visibility_policy, alias)
5709 {
5710 return identity_hidden_by_policy_response(response_id, identity);
5711 }
5712 let mid = crate::member_comms_id::mob_member_id(
5713 alias.runtime_member_id.as_str(),
5714 );
5715 return match retire_console_member(&handle, &mid).await {
5716 Ok(()) => {
5717 if let Some(store) = &console_events {
5718 store
5719 .record_lifecycle(
5720 &alias.identity,
5721 "identity_retired",
5722 json!({}),
5723 )
5724 .await;
5725 }
5726 response_value(
5727 response_id,
5728 Some(json!({ "identity": alias.identity })),
5729 None,
5730 )
5731 }
5732 Err(err) => {
5733 internal_error(response_id, format!("retire failed: {err}"))
5734 }
5735 };
5736 }
5737 }
5738 Err(err) => return console_identity_error_response(response_id, "retire", err),
5739 }
5740 }
5741 if let Some(aggregator) = &console_aggregator {
5742 let canonical_identity = match Box::pin(aggregator.inspect_identity(identity)).await
5743 {
5744 Ok(Some(inspection)) => inspection.identity.identity,
5745 Ok(None) => identity.to_string(),
5746 Err(_) => identity.to_string(),
5747 };
5748 return match Box::pin(aggregator.retire_identity(identity)).await {
5749 Ok(true) => {
5750 if let Some(store) = &console_events {
5751 store
5752 .record_lifecycle(
5753 &canonical_identity,
5754 "identity_retired",
5755 json!({}),
5756 )
5757 .await;
5758 }
5759 response_value(
5760 response_id,
5761 Some(json!({ "identity": canonical_identity })),
5762 None,
5763 )
5764 }
5765 Ok(false) => response_value(
5766 response_id,
5767 None,
5768 Some(JsonRpcError {
5769 code: -32001,
5770 message: format!("unknown identity: {identity}"),
5771 data: None,
5772 }),
5773 ),
5774 Err(err) => internal_error(response_id, format!("retire failed: {err}")),
5775 };
5776 }
5777 let live_alias = match lookup_member_alias_with_session(
5778 &handle,
5779 visibility_policy,
5780 identity,
5781 )
5782 .await
5783 {
5784 Ok(alias) => alias,
5785 Err(err) => return response_value(response_id, None, Some(err)),
5786 };
5787 let Some(alias) = live_alias else {
5788 return invalid_params(response_id, format!("identity not found: {identity}"));
5789 };
5790 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
5791 return identity_hidden_by_policy_response(response_id, identity);
5792 }
5793 if let Err(err) =
5794 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
5795 {
5796 return response_value(response_id, None, Some(err));
5797 }
5798 let mid = crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str());
5799 match retire_console_member(&handle, &mid).await {
5800 Ok(()) => {
5801 if let Some(store) = &console_events {
5802 store
5803 .record_lifecycle(&alias.identity, "identity_retired", json!({}))
5804 .await;
5805 }
5806 response_value(
5807 response_id,
5808 Some(json!({ "identity": alias.identity })),
5809 None,
5810 )
5811 }
5812 Err(err) => internal_error(response_id, format!("retire failed: {err}")),
5813 }
5814 }
5815 "mobkit/respawn" => {
5816 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5817 return invalid_params(response_id, "identity required");
5818 };
5819 let handle = runtime.handle();
5820 if let Some(identity_runtime) = &identity_runtime {
5821 let (parsed_identity, _requested_exact_identity, live_alias) =
5822 match resolve_console_identity_control_target(
5823 &handle,
5824 Some(identity_runtime),
5825 visibility_policy,
5826 identity,
5827 )
5828 .await
5829 {
5830 Ok(Some(target)) => target,
5831 Ok(None) => {
5832 return invalid_params(
5833 response_id,
5834 format!("identity not found: {identity}"),
5835 );
5836 }
5837 Err(err) => return response_value(response_id, None, Some(err)),
5838 };
5839 let registered_status = match identity_runtime.status(&parsed_identity).await {
5840 Ok(status) => Some(status),
5841 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => None,
5842 Err(err) => {
5843 return console_identity_error_response(response_id, "respawn", err);
5844 }
5845 };
5846 if let Some(status) = registered_status.as_ref() {
5847 if !identity_status_visible_to_console(visibility_policy, status) {
5848 return identity_hidden_by_policy_response(response_id, identity);
5849 }
5850 if let Some(error) = stale_live_alias_json_rpc_error(
5851 "respawn",
5852 identity_runtime,
5853 &parsed_identity,
5854 live_alias.as_ref(),
5855 )
5856 .await
5857 {
5858 return response_value(response_id, None, Some(error));
5859 }
5860 }
5861 match identity_runtime.respawn(&parsed_identity).await {
5862 Ok(mut record) => {
5863 let live_respawn_warning = match Box::pin(respawn_console_member(
5864 &handle,
5865 &crate::member_comms_id::mob_member_id(
5866 record.agent_runtime_id.as_str(),
5867 ),
5868 ))
5869 .await
5870 {
5871 Ok(topology_restore_warning) => {
5872 let live_session_id = handle
5873 .resolve_bridge_session_id_observation(
5874 &crate::member_comms_id::mob_member_id(
5875 record.agent_runtime_id.as_str(),
5876 ),
5877 )
5878 .await;
5879 if let Some(live_session_id) = live_session_id {
5880 match identity_runtime
5881 .rebind_session_after_live_respawn(
5882 &parsed_identity,
5883 live_session_id.clone(),
5884 )
5885 .await
5886 {
5887 Ok(updated_record) => {
5888 record = updated_record;
5889 topology_restore_warning
5890 }
5891 Err(err) => Some(json!({
5892 "kind": "identity_rebind_failed_after_member_respawn",
5893 "identity": record.identity.as_str(),
5894 "agent_runtime_id": record.agent_runtime_id.as_str(),
5895 "live_session_id": live_session_id.to_string(),
5896 "message": err.to_string(),
5897 })),
5898 }
5899 } else {
5900 topology_restore_warning
5901 }
5902 }
5903 Err(err) => Some(json!({
5904 "kind": "member_respawn_failed_after_identity_refresh",
5905 "identity": record.identity.as_str(),
5906 "agent_runtime_id": record.agent_runtime_id.as_str(),
5907 "message": err,
5908 })),
5909 };
5910 let cleanup_warning = if registered_status.is_some()
5911 && let Err(err) = retire_stale_console_members_for_identity(
5912 &handle,
5913 visibility_policy,
5914 parsed_identity.as_str(),
5915 Some(record.agent_runtime_id.as_str()),
5916 )
5917 .await
5918 {
5919 Some(json!({
5920 "kind": "stale_member_cleanup_failed_after_identity_respawn",
5921 "identity": parsed_identity.as_str(),
5922 "agent_runtime_id": record.agent_runtime_id.as_str(),
5923 "message": err,
5924 }))
5925 } else {
5926 None
5927 };
5928 if let Some(store) = &console_events {
5929 store
5930 .record_lifecycle(
5931 parsed_identity.as_str(),
5932 "identity_respawned",
5933 json!({
5934 "generation": record.generation.get(),
5935 "checkpoint_version": record.checkpoint_version.get(),
5936 "live_respawn_warning": live_respawn_warning.clone(),
5937 "cleanup_warning": cleanup_warning.clone(),
5938 }),
5939 )
5940 .await;
5941 }
5942 return response_value(
5943 response_id,
5944 Some(json!({
5945 "identity": record.identity.as_str(),
5946 "agent_runtime_id": record.agent_runtime_id.as_str(),
5947 "session_id": record.session_id.to_string(),
5948 "generation": record.generation.get(),
5949 "checkpoint_version": record.checkpoint_version.get(),
5950 "live_respawn_warning": live_respawn_warning,
5951 "cleanup_warning": cleanup_warning,
5952 })),
5953 None,
5954 );
5955 }
5956 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
5957 if live_alias.is_none() {
5958 return invalid_params(
5959 response_id,
5960 format!("identity not found: {identity}"),
5961 );
5962 }
5963 }
5964 Err(err) => {
5965 return console_identity_error_response(response_id, "respawn", err);
5966 }
5967 }
5968 }
5969 let live_alias = match lookup_member_alias_with_session(
5970 &handle,
5971 visibility_policy,
5972 identity,
5973 )
5974 .await
5975 {
5976 Ok(alias) => alias,
5977 Err(err) => return response_value(response_id, None, Some(err)),
5978 };
5979 let Some(alias) = live_alias else {
5980 return invalid_params(response_id, format!("identity not found: {identity}"));
5981 };
5982 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
5983 return identity_hidden_by_policy_response(response_id, identity);
5984 }
5985 if let Err(err) =
5986 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
5987 {
5988 return response_value(response_id, None, Some(err));
5989 }
5990 Box::pin(respawn_live_console_member_response(
5991 &handle,
5992 console_events.as_ref(),
5993 &alias,
5994 "identity_respawned",
5995 "respawn",
5996 response_id,
5997 ))
5998 .await
5999 }
6000 "mobkit/reset" => {
6001 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6002 return invalid_params(response_id, "identity required");
6003 };
6004 let handle = runtime.handle();
6005 let Some(identity_runtime) = &identity_runtime else {
6006 let live_alias =
6012 match lookup_member_alias_with_session(&handle, visibility_policy, identity)
6013 .await
6014 {
6015 Ok(alias) => alias,
6016 Err(err) => return response_value(response_id, None, Some(err)),
6017 };
6018 let Some(alias) = live_alias else {
6019 return invalid_params(response_id, format!("identity not found: {identity}"));
6020 };
6021 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6022 return identity_hidden_by_policy_response(response_id, identity);
6023 }
6024 if let Err(err) =
6025 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias)
6026 .await
6027 {
6028 return response_value(response_id, None, Some(err));
6029 }
6030 return Box::pin(respawn_live_console_member_response(
6031 &handle,
6032 console_events.as_ref(),
6033 &alias,
6034 "identity_reset",
6035 "reset",
6036 response_id,
6037 ))
6038 .await;
6039 };
6040 let (parsed_identity, _requested_exact_identity, live_alias) =
6041 match resolve_console_identity_control_target(
6042 &handle,
6043 Some(identity_runtime),
6044 visibility_policy,
6045 identity,
6046 )
6047 .await
6048 {
6049 Ok(Some(target)) => target,
6050 Ok(None) => {
6051 return invalid_params(
6052 response_id,
6053 format!("identity not found: {identity}"),
6054 );
6055 }
6056 Err(err) => return response_value(response_id, None, Some(err)),
6057 };
6058 match identity_runtime.status(&parsed_identity).await {
6059 Ok(status) => {
6060 if !identity_status_visible_to_console(visibility_policy, &status) {
6061 return identity_hidden_by_policy_response(response_id, identity);
6062 }
6063 if let Some(error) = stale_live_alias_json_rpc_error(
6064 "reset",
6065 identity_runtime,
6066 &parsed_identity,
6067 live_alias.as_ref(),
6068 )
6069 .await
6070 {
6071 return response_value(response_id, None, Some(error));
6072 }
6073 if !identity_runtime.has_session_bridge() {
6074 return response_value(
6075 response_id,
6076 None,
6077 Some(reset_requires_session_bridge_json_rpc_error()),
6078 );
6079 }
6080 }
6081 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6082 if let Some(alias) = live_alias.as_ref() {
6083 if !runtime_alias_visible_to_console(&handle, visibility_policy, alias) {
6084 return identity_hidden_by_policy_response(response_id, identity);
6085 }
6086 return Box::pin(respawn_live_console_member_response(
6087 &handle,
6088 console_events.as_ref(),
6089 alias,
6090 "identity_reset",
6091 "reset",
6092 response_id,
6093 ))
6094 .await;
6095 }
6096 return invalid_params(response_id, format!("identity not found: {identity}"));
6097 }
6098 Err(err) => return console_identity_error_response(response_id, "reset", err),
6099 }
6100 match identity_runtime.reset(&parsed_identity).await {
6101 Ok(record) => {
6102 let cleanup_warning = Some(json!({
6103 "kind": "stale_member_cleanup_skipped_after_identity_reset",
6104 "identity": parsed_identity.as_str(),
6105 "agent_runtime_id": record.agent_runtime_id.as_str(),
6106 "message": "reset published the new generation without retiring stale live mob members; identity control calls reject stale runtime ids",
6107 }));
6108 if let Some(store) = &console_events {
6109 store
6110 .record_lifecycle(
6111 parsed_identity.as_str(),
6112 "identity_reset",
6113 json!({
6114 "generation": record.generation.get(),
6115 "checkpoint_version": record.checkpoint_version.get(),
6116 "cleanup_warning": cleanup_warning.clone(),
6117 }),
6118 )
6119 .await;
6120 }
6121 response_value(
6122 response_id,
6123 Some(json!({
6124 "identity": record.identity.as_str(),
6125 "agent_runtime_id": record.agent_runtime_id.as_str(),
6126 "session_id": record.session_id.to_string(),
6127 "generation": record.generation.get(),
6128 "checkpoint_version": record.checkpoint_version.get(),
6129 "cleanup_warning": cleanup_warning,
6130 })),
6131 None,
6132 )
6133 }
6134 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6135 invalid_params(response_id, format!("identity not found: {identity}"))
6136 }
6137 Err(err) => console_identity_error_response(response_id, "reset", err),
6138 }
6139 }
6140 "mobkit/delete_identity" => {
6141 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6142 return invalid_params(response_id, "identity required");
6143 };
6144 let handle = runtime.handle();
6145 let Some(identity_runtime) = &identity_runtime else {
6146 return invalid_params(
6147 response_id,
6148 "identity-first runtime required for delete_identity",
6149 );
6150 };
6151 let (parsed_identity, _requested_exact_identity, live_alias) =
6152 match resolve_console_identity_control_target(
6153 &handle,
6154 Some(identity_runtime),
6155 visibility_policy,
6156 identity,
6157 )
6158 .await
6159 {
6160 Ok(Some(target)) => target,
6161 Ok(None) => {
6162 return invalid_params(
6163 response_id,
6164 format!("identity not found: {identity}"),
6165 );
6166 }
6167 Err(err) => return response_value(response_id, None, Some(err)),
6168 };
6169 let registered_status = match identity_runtime.status(&parsed_identity).await {
6170 Ok(status) => status,
6171 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6172 if let Some(alias) = live_alias.as_ref() {
6173 if !runtime_alias_visible_to_console(&handle, visibility_policy, alias) {
6174 return identity_hidden_by_policy_response(response_id, identity);
6175 }
6176 return response_value(
6177 response_id,
6178 None,
6179 Some(JsonRpcError {
6180 code: -32602,
6181 message: format!(
6182 "delete_identity requires durable identity: {} is live-only",
6183 parsed_identity.as_str()
6184 ),
6185 data: Some(json!({
6186 "kind": "live_only_identity_delete_unsupported",
6187 "identity": parsed_identity.as_str(),
6188 })),
6189 }),
6190 );
6191 }
6192 return invalid_params(response_id, format!("identity not found: {identity}"));
6193 }
6194 Err(err) => {
6195 return console_identity_error_response(response_id, "delete_identity", err);
6196 }
6197 };
6198 if !identity_status_visible_to_console(visibility_policy, ®istered_status) {
6199 return identity_hidden_by_policy_response(response_id, identity);
6200 }
6201 if let Some(error) = stale_live_alias_json_rpc_error(
6202 "delete_identity",
6203 identity_runtime,
6204 &parsed_identity,
6205 live_alias.as_ref(),
6206 )
6207 .await
6208 {
6209 return response_value(response_id, None, Some(error));
6210 }
6211 match identity_runtime.delete_identity(&parsed_identity).await {
6212 Ok(()) => {
6213 let keep_runtime_member_id = registered_status
6214 .agent_runtime_id
6215 .as_ref()
6216 .filter(|_| identity_runtime.has_session_bridge())
6217 .map(crate::identity_first::AgentRuntimeId::as_str);
6218 let cleanup_warning = if let Err(err) =
6219 retire_stale_console_members_for_identity(
6220 &handle,
6221 visibility_policy,
6222 parsed_identity.as_str(),
6223 keep_runtime_member_id,
6224 )
6225 .await
6226 {
6227 Some(json!({
6228 "kind": "stale_member_cleanup_failed_after_identity_delete",
6229 "identity": parsed_identity.as_str(),
6230 "message": err,
6231 }))
6232 } else {
6233 None
6234 };
6235 if let Some(store) = &console_events {
6236 store
6237 .record_lifecycle(
6238 parsed_identity.as_str(),
6239 "identity_deleted",
6240 json!({
6241 "cleanup_warning": cleanup_warning.clone(),
6242 }),
6243 )
6244 .await;
6245 }
6246 response_value(
6247 response_id,
6248 Some(json!({
6249 "identity": parsed_identity.as_str(),
6250 "cleanup_warning": cleanup_warning,
6251 })),
6252 None,
6253 )
6254 }
6255 Err(err) => console_identity_error_response(response_id, "delete_identity", err),
6256 }
6257 }
6258 "mobkit/reset_all" => {
6259 match Box::pin(reset_all_live_console_agents(
6260 runtime,
6261 console_events.as_ref(),
6262 console_aggregator.as_ref(),
6263 identity_runtime.as_ref(),
6264 visibility_policy,
6265 ))
6266 .await
6267 {
6268 Ok(body) => {
6269 if body
6270 .get("failed")
6271 .and_then(Value::as_array)
6272 .is_some_and(|failed| !failed.is_empty())
6273 {
6274 response_value(
6275 response_id,
6276 None,
6277 Some(JsonRpcError {
6278 code: -32000,
6279 message: "reset_all failed for one or more identities".to_string(),
6280 data: Some(body),
6281 }),
6282 )
6283 } else {
6284 response_value(response_id, Some(body), None)
6285 }
6286 }
6287 Err(err) => internal_error(response_id, format!("reset_all failed: {err}")),
6288 }
6289 }
6290 "mobkit/routing/routes/list" => {
6291 let Some(module_runtime) = &module_runtime else {
6292 return response_value(
6293 response_id,
6294 None,
6295 Some(JsonRpcError {
6296 code: -32601,
6297 message: "Method not found".to_string(),
6298 data: None,
6299 }),
6300 );
6301 };
6302 let routes = module_runtime.lock().await.list_runtime_routes();
6303 response_value(response_id, Some(json!({ "routes": routes })), None)
6304 }
6305 "mobkit/delivery/history" => {
6306 let Some(module_runtime) = &module_runtime else {
6307 return response_value(
6308 response_id,
6309 None,
6310 Some(JsonRpcError {
6311 code: -32601,
6312 message: "Method not found".to_string(),
6313 data: None,
6314 }),
6315 );
6316 };
6317 let limit = request
6318 .params
6319 .get("limit")
6320 .and_then(Value::as_u64)
6321 .unwrap_or(50) as usize;
6322 let history = module_runtime
6323 .lock()
6324 .await
6325 .delivery_history(DeliveryHistoryRequest {
6326 recipient: None,
6327 sink: None,
6328 limit,
6329 });
6330 response_value(
6331 response_id,
6332 Some(serde_json::to_value(history).unwrap_or(Value::Null)),
6333 None,
6334 )
6335 }
6336 "mobkit/gating/pending" => {
6337 let Some(module_runtime) = &module_runtime else {
6338 return response_value(
6339 response_id,
6340 None,
6341 Some(JsonRpcError {
6342 code: -32601,
6343 message: "Method not found".to_string(),
6344 data: None,
6345 }),
6346 );
6347 };
6348 let pending = module_runtime.lock().await.list_gating_pending();
6349 response_value(response_id, Some(json!({ "pending": pending })), None)
6350 }
6351 "mobkit/gating/audit" => {
6352 let Some(module_runtime) = &module_runtime else {
6353 return response_value(
6354 response_id,
6355 None,
6356 Some(JsonRpcError {
6357 code: -32601,
6358 message: "Method not found".to_string(),
6359 data: None,
6360 }),
6361 );
6362 };
6363 let limit = request
6364 .params
6365 .get("limit")
6366 .and_then(Value::as_u64)
6367 .unwrap_or(50) as usize;
6368 let entries = module_runtime.lock().await.gating_audit_entries(limit);
6369 response_value(response_id, Some(json!({ "entries": entries })), None)
6370 }
6371 "mobkit/gating/decide" => {
6372 let Some(module_runtime) = &module_runtime else {
6373 return response_value(
6374 response_id,
6375 None,
6376 Some(JsonRpcError {
6377 code: -32601,
6378 message: "Method not found".to_string(),
6379 data: None,
6380 }),
6381 );
6382 };
6383 let Some(pending_id) = request.params.get("pending_id").and_then(Value::as_str) else {
6384 return invalid_params(response_id, "pending_id required");
6385 };
6386 let approver_id =
6387 match resolve_gating_approver_id(&request.params, authenticated_principal) {
6388 Ok(approver_id) => approver_id,
6389 Err(message) => return invalid_params(response_id, message),
6390 };
6391 let Some(raw_decision) = request.params.get("decision").and_then(Value::as_str) else {
6392 return invalid_params(response_id, "decision required");
6393 };
6394 let decision = match raw_decision {
6395 "approve" => GatingDecision::Approve,
6396 "reject" | "deny" => GatingDecision::Reject,
6397 "escalate" => GatingDecision::Escalate,
6398 _ => {
6399 return invalid_params(
6400 response_id,
6401 format!("unsupported decision: {raw_decision}"),
6402 );
6403 }
6404 };
6405 let reason = request
6406 .params
6407 .get("reason")
6408 .and_then(Value::as_str)
6409 .map(ToString::to_string);
6410 match module_runtime
6411 .lock()
6412 .await
6413 .decide_gating_action(GatingDecideRequest {
6414 pending_id: pending_id.to_string(),
6415 approver_id,
6416 decision,
6417 reason,
6418 }) {
6419 Ok(result) => response_value(
6420 response_id,
6421 Some(serde_json::to_value(result).unwrap_or(Value::Null)),
6422 None,
6423 ),
6424 Err(err) => gating_decision_failed_error(response_id, err),
6425 }
6426 }
6427 "mobkit/ensure_member" => {
6428 let Some(role) = request.params.get("role").and_then(Value::as_str) else {
6429 return invalid_params(response_id, "role required");
6430 };
6431 let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
6432 else {
6433 return invalid_params(response_id, "agent_identity required");
6434 };
6435 let labels = match request.params.get("labels") {
6436 None | Some(Value::Null) => std::collections::BTreeMap::new(),
6437 Some(value) => match serde_json::from_value(value.clone()) {
6438 Ok(map) => map,
6439 Err(err) => {
6440 return invalid_params(response_id, format!("invalid labels: {err}"));
6441 }
6442 },
6443 };
6444 let context = request.params.get("context").cloned();
6445 let resume_session_id = match request.params.get("resume_session_id") {
6446 None => None,
6447 Some(Value::Null) => None,
6448 Some(v) => match v.as_str() {
6449 Some(s) => match meerkat_core::types::SessionId::parse(s) {
6450 Ok(sid) => Some(sid),
6451 Err(_) => {
6452 return invalid_params(
6453 response_id,
6454 format!("invalid resume_session_id: {s:?}"),
6455 );
6456 }
6457 },
6458 None => {
6459 return invalid_params(
6460 response_id,
6461 "resume_session_id must be a string".to_string(),
6462 );
6463 }
6464 },
6465 };
6466 let additional_instructions = match request.params.get("additional_instructions") {
6467 None | Some(Value::Null) => None,
6468 Some(Value::Array(arr)) => {
6469 let mut strs = Vec::with_capacity(arr.len());
6470 for (i, entry) in arr.iter().enumerate() {
6471 match entry.as_str() {
6472 Some(s) => strs.push(s.to_string()),
6473 None => {
6474 return invalid_params(
6475 response_id,
6476 format!("additional_instructions[{i}] must be a string"),
6477 );
6478 }
6479 }
6480 }
6481 if strs.is_empty() { None } else { Some(strs) }
6482 }
6483 Some(_) => {
6484 return invalid_params(
6485 response_id,
6486 "additional_instructions must be an array of strings",
6487 );
6488 }
6489 };
6490 let runtime_mode = match parse_optional_runtime_mode(&request.params) {
6491 Ok(value) => value,
6492 Err(message) => return invalid_params(response_id, message),
6493 };
6494 let backend = match parse_optional_backend(&request.params) {
6495 Ok(value) => value,
6496 Err(message) => return invalid_params(response_id, message),
6497 };
6498 let binding = match parse_optional_runtime_binding(&request.params) {
6499 Ok(value) => value,
6500 Err(message) => return invalid_params(response_id, message),
6501 };
6502 let mut spec = SpawnMemberSpec::new(
6503 ProfileName::from(role),
6504 crate::member_comms_id::mob_member_id(agent_identity),
6505 );
6506 if let Some(runtime_mode) = runtime_mode {
6507 spec = spec.with_runtime_mode(runtime_mode);
6508 }
6509 if let Some(backend) = backend {
6510 spec = spec.with_backend(backend);
6511 }
6512 if let Some(binding) = binding {
6513 spec.binding = Some(binding);
6514 }
6515 if !labels.is_empty() {
6516 spec = spec.with_labels(labels);
6517 }
6518 if let Some(ctx) = context {
6519 spec = spec.with_context(ctx);
6520 }
6521 if let Some(sid) = resume_session_id {
6522 spec = spec.with_resume_bridge_session_id(sid);
6523 }
6524 if let Some(instructions) = additional_instructions {
6525 spec = spec.with_additional_instructions(instructions);
6526 }
6527 let handle = runtime.handle();
6528 let mid = spec.identity.clone();
6529 match handle.ensure_member(spec).await {
6530 Ok(_outcome) => {
6531 let body = match lookup_member_with_session(&handle, &mid).await {
6532 Some((entry, _sid)) => member_entry_to_json(&entry),
6533 None => Value::Null,
6534 };
6535 response_value(response_id, Some(body), None)
6536 }
6537 Err(err) => internal_error(response_id, format!("ensure_member failed: {err}")),
6538 }
6539 }
6540 "mobkit/retire_member" => {
6541 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
6542 return invalid_params(response_id, "member_id required");
6543 };
6544 if let Some(error) = stale_runtime_alias_json_rpc_error(
6545 "retire_member",
6546 identity_runtime.as_ref(),
6547 member_id,
6548 )
6549 .await
6550 {
6551 return response_value(response_id, None, Some(error));
6552 }
6553 if let Some(aggregator) = &console_aggregator {
6554 return match Box::pin(aggregator.retire_identity(member_id)).await {
6555 Ok(true) => response_value(
6556 response_id,
6557 Some(serde_json::json!({ "accepted": true })),
6558 None,
6559 ),
6560 Ok(false) => response_value(
6561 response_id,
6562 None,
6563 Some(JsonRpcError {
6564 code: -32001,
6565 message: format!("unknown identity: {member_id}"),
6566 data: None,
6567 }),
6568 ),
6569 Err(err) => internal_error(response_id, format!("retire_member failed: {err}")),
6570 };
6571 }
6572 match runtime
6573 .handle()
6574 .retire(crate::member_comms_id::mob_member_id(member_id))
6575 .await
6576 {
6577 Ok(()) => response_value(
6578 response_id,
6579 Some(serde_json::json!({ "accepted": true })),
6580 None,
6581 ),
6582 Err(err) => internal_error(response_id, format!("retire_member failed: {err}")),
6583 }
6584 }
6585 "mobkit/respawn_member" => {
6586 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
6587 return invalid_params(response_id, "member_id required");
6588 };
6589 if let Some(error) = stale_runtime_alias_json_rpc_error(
6590 "respawn_member",
6591 identity_runtime.as_ref(),
6592 member_id,
6593 )
6594 .await
6595 {
6596 return response_value(response_id, None, Some(error));
6597 }
6598 match runtime
6599 .handle()
6600 .respawn(crate::member_comms_id::mob_member_id(member_id), None)
6601 .await
6602 {
6603 Ok(_receipt) => response_value(
6604 response_id,
6605 Some(serde_json::json!({ "accepted": true })),
6606 None,
6607 ),
6608 Err(err) => {
6609 if let Some(failed_peer_ids) = topology_restore_failed_peer_ids(&err) {
6610 tracing::warn!(
6611 member_id = %member_id,
6612 failed_peer_count = failed_peer_ids.len(),
6613 failed_peer_ids = ?failed_peer_ids,
6614 "console member respawn restored member with isolated peer edges; accepting degraded respawn"
6615 );
6616 response_value(
6617 response_id,
6618 Some(serde_json::json!({
6619 "accepted": true,
6620 "topology_restore_warning": topology_restore_warning_json(&failed_peer_ids),
6621 })),
6622 None,
6623 )
6624 } else {
6625 internal_error(response_id, format!("respawn_member failed: {err}"))
6626 }
6627 }
6628 }
6629 }
6630 "mobkit/reconcile_edges" => response_value(
6631 response_id,
6632 Some(serde_json::json!({
6633 "status": "noop",
6634 "reason": "console runtime routes directly to MobRuntime",
6635 })),
6636 None,
6637 ),
6638 "mobkit/mob_events/query" | "mobkit/mob_events/subscribe" => {
6639 let query: EventQuery = if request.params.is_null() {
6640 EventQuery::default()
6641 } else {
6642 match serde_json::from_value(request.params.clone()) {
6643 Ok(q) => q,
6644 Err(err) => {
6645 return invalid_params(response_id, format!("invalid query params: {err}"));
6646 }
6647 }
6648 };
6649 let Some(store) = mob_events.as_ref() else {
6650 return response_value(
6651 response_id,
6652 Some(serde_json::json!({
6653 "events": [],
6654 "next_after_seq": Value::Null,
6655 })),
6656 None,
6657 );
6658 };
6659 let events_view = runtime.handle().events();
6660 let latest_at_handshake = events_view.latest_cursor().await.unwrap_or(0);
6664 let result = crate::unified_runtime::mob_events::query_ledger_with_filter(
6665 &events_view,
6666 store,
6667 &query,
6668 )
6669 .await;
6670 match result {
6671 Ok(mut events) => {
6672 if let Some(view) = access_view.filter(|view| view.enforced()) {
6678 events.retain(|event| {
6679 event
6680 .agent_identity
6681 .as_deref()
6682 .is_none_or(|identity| view.can_view_agent(identity))
6683 });
6684 }
6685 let last_cursor = events.last().map(|event| event.cursor);
6686 let body = if request.method == "mobkit/mob_events/subscribe" {
6687 let subscribe_url = crate::unified_runtime::mob_events::build_subscribe_url(
6688 &query,
6689 last_cursor,
6690 latest_at_handshake,
6691 );
6692 serde_json::json!({
6693 "stream": "mob_events",
6694 "events": events,
6695 "next_after_seq": last_cursor,
6696 "subscribe_url": subscribe_url,
6697 "keep_alive": {
6698 "interval_ms": 15_000_u64,
6699 "event": "keep_alive",
6700 },
6701 })
6702 } else {
6703 serde_json::json!({
6704 "events": events,
6705 "next_after_seq": last_cursor,
6706 })
6707 };
6708 response_value(response_id, Some(body), None)
6709 }
6710 Err(crate::unified_runtime::mob_events::MobEventsQueryError::Stale {
6711 after_cursor,
6712 latest_cursor,
6713 }) => stale_event_cursor_response(response_id, after_cursor, latest_cursor),
6714 Err(err) => internal_error(response_id, format!("mob_events query failed: {err}")),
6715 }
6716 }
6717 "mobkit/member_status" => {
6719 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
6720 return invalid_params(response_id, "member_id required");
6721 };
6722 if let Some(error) = stale_runtime_alias_json_rpc_error(
6723 "member_status",
6724 identity_runtime.as_ref(),
6725 member_id,
6726 )
6727 .await
6728 {
6729 return response_value(response_id, None, Some(error));
6730 }
6731 match runtime
6732 .handle()
6733 .member_status(&crate::member_comms_id::mob_member_id(member_id))
6734 .await
6735 {
6736 Ok(snapshot) => response_value(
6737 response_id,
6738 Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
6739 None,
6740 ),
6741 Err(err) => internal_error(response_id, format!("member_status failed: {err}")),
6742 }
6743 }
6744 "mobkit/identity/resolved_tools" => {
6745 let Some(identity) = request
6746 .params
6747 .get("identity")
6748 .or_else(|| request.params.get("member_id"))
6749 .and_then(Value::as_str)
6750 else {
6751 return invalid_params(response_id, "identity required");
6752 };
6753 if let Some(error) = stale_runtime_alias_json_rpc_error(
6754 "identity_resolved_tools",
6755 identity_runtime.as_ref(),
6756 identity,
6757 )
6758 .await
6759 {
6760 return response_value(response_id, None, Some(error));
6761 }
6762 if let Some(identity_runtime) = identity_runtime.as_ref()
6763 && let Ok(parsed) = crate::identity_first::AgentIdentity::parse(identity)
6764 && let Ok(status) = identity_runtime.status(&parsed).await
6765 && let Some(session_id) = status.session_id
6766 {
6767 match resolved_tools_for_session(runtime.session_service(), identity, session_id)
6768 .await
6769 {
6770 Ok(snapshot) => {
6771 return response_value(
6772 response_id,
6773 Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
6774 None,
6775 );
6776 }
6777 Err(err) => {
6778 return internal_error(
6779 response_id,
6780 format!("resolved_tools failed: {err}"),
6781 );
6782 }
6783 }
6784 }
6785 match resolved_tools_for_member(&runtime.handle(), runtime.session_service(), identity)
6786 .await
6787 {
6788 Ok(snapshot) => response_value(
6789 response_id,
6790 Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
6791 None,
6792 ),
6793 Err(err) => internal_error(response_id, format!("resolved_tools failed: {err}")),
6794 }
6795 }
6796 "mobkit/force_cancel_member" => {
6797 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
6798 return invalid_params(response_id, "member_id required");
6799 };
6800 if let Some(error) = stale_runtime_alias_json_rpc_error(
6801 "force_cancel_member",
6802 identity_runtime.as_ref(),
6803 member_id,
6804 )
6805 .await
6806 {
6807 return response_value(response_id, None, Some(error));
6808 }
6809 match runtime
6810 .handle()
6811 .force_cancel_member(crate::member_comms_id::mob_member_id(member_id))
6812 .await
6813 {
6814 Ok(()) => response_value(
6815 response_id,
6816 Some(serde_json::json!({ "accepted": true })),
6817 None,
6818 ),
6819 Err(err) => {
6820 internal_error(response_id, format!("force_cancel_member failed: {err}"))
6821 }
6822 }
6823 }
6824 "mobkit/wait_ready" => {
6825 let timeout = Some(
6829 request
6830 .params
6831 .get("timeout_ms")
6832 .and_then(Value::as_u64)
6833 .map(std::time::Duration::from_millis)
6834 .unwrap_or(crate::unified_runtime::mob_ops::DEFAULT_WAIT_READY_TIMEOUT),
6835 );
6836 match runtime.handle().wait_for_ready(timeout).await {
6837 Ok(ready) => {
6838 let entries: Vec<Value> = ready
6839 .into_iter()
6840 .filter_map(|(identity, snapshot)| {
6844 let identity = identity.to_string();
6845 let visible = access_view
6846 .filter(|view| view.enforced())
6847 .is_none_or(|view| view.can_view_agent(&identity));
6848 visible.then(|| {
6849 serde_json::json!({
6850 "agent_identity": identity,
6851 "snapshot": serde_json::to_value(&snapshot)
6852 .unwrap_or(Value::Null),
6853 })
6854 })
6855 })
6856 .collect();
6857 response_value(
6858 response_id,
6859 Some(serde_json::json!({
6860 "ready": entries,
6861 "timeout": false,
6862 })),
6863 None,
6864 )
6865 }
6866 Err(err) => {
6867 if crate::unified_runtime::mob_ops::is_ready_wait_timeout(&err) {
6868 response_value(
6869 response_id,
6870 Some(serde_json::json!({
6871 "ready": Vec::<Value>::new(),
6872 "timeout": true,
6873 })),
6874 None,
6875 )
6876 } else {
6877 internal_error(response_id, format!("wait_for_ready failed: {err}"))
6878 }
6879 }
6880 }
6881 }
6882 "mobkit/collect_completed" => {
6883 let completed = runtime.handle().collect_completed().await;
6884 let entries: Vec<Value> = completed
6885 .into_iter()
6886 .filter_map(|(mid, snapshot)| {
6889 let mid = mid.to_string();
6890 let visible = access_view
6891 .filter(|view| view.enforced())
6892 .is_none_or(|view| view.can_view_agent(&mid));
6893 visible.then(|| {
6894 serde_json::json!({
6895 "member_id": mid,
6896 "snapshot": serde_json::to_value(&snapshot).unwrap_or(Value::Null),
6897 })
6898 })
6899 })
6900 .collect();
6901 response_value(
6902 response_id,
6903 Some(serde_json::json!({ "completed": entries })),
6904 None,
6905 )
6906 }
6907 "mobkit/cancel_flow" => {
6908 let Some(run_id) = request.params.get("run_id").and_then(Value::as_str) else {
6909 return invalid_params(response_id, "run_id required");
6910 };
6911 let run_id: meerkat_mob::RunId = match run_id.parse() {
6912 Ok(id) => id,
6913 Err(_) => return invalid_params(response_id, "invalid run_id format"),
6914 };
6915 match runtime.handle().cancel_flow(run_id).await {
6916 Ok(()) => response_value(
6917 response_id,
6918 Some(serde_json::json!({ "accepted": true })),
6919 None,
6920 ),
6921 Err(err) => internal_error(response_id, format!("cancel_flow failed: {err}")),
6922 }
6923 }
6924 "mobkit/flow_status" => {
6925 let Some(run_id) = request.params.get("run_id").and_then(Value::as_str) else {
6926 return invalid_params(response_id, "run_id required");
6927 };
6928 let run_id: meerkat_mob::RunId = match run_id.parse() {
6929 Ok(id) => id,
6930 Err(_) => return invalid_params(response_id, "invalid run_id format"),
6931 };
6932 match runtime.handle().flow_status(run_id).await {
6933 Ok(Some(mob_run)) => response_value(
6934 response_id,
6935 Some(serde_json::to_value(&mob_run).unwrap_or(Value::Null)),
6936 None,
6937 ),
6938 Ok(None) => response_value(response_id, Some(Value::Null), None),
6939 Err(err) => internal_error(response_id, format!("flow_status failed: {err}")),
6940 }
6941 }
6942 "mobkit/list_flows" => {
6943 let flows: Vec<String> = runtime
6944 .handle()
6945 .list_flows()
6946 .into_iter()
6947 .map(|id| id.to_string())
6948 .collect();
6949 response_value(
6950 response_id,
6951 Some(serde_json::json!({ "flows": flows })),
6952 None,
6953 )
6954 }
6955 "mobkit/list_runs" => {
6956 let flow_id = request
6957 .params
6958 .get("flow_id")
6959 .and_then(Value::as_str)
6960 .filter(|value| !value.is_empty())
6961 .map(meerkat_mob::FlowId::from);
6962 match runtime.handle().list_runs(flow_id.as_ref()).await {
6963 Ok(runs) => response_value(
6964 response_id,
6965 Some(serde_json::json!({
6966 "runs": serde_json::to_value(&runs).unwrap_or(Value::Null),
6967 })),
6968 None,
6969 ),
6970 Err(err) => internal_error(response_id, format!("list_runs failed: {err}")),
6971 }
6972 }
6973 "mobkit/run_flow" => {
6974 let Some(flow_id_str) = request.params.get("flow_id").and_then(Value::as_str) else {
6975 return invalid_params(response_id, "flow_id required");
6976 };
6977 if flow_id_str.is_empty() {
6978 return invalid_params(response_id, "flow_id required");
6979 }
6980 let flow_id = meerkat_mob::FlowId::from(flow_id_str);
6981 let flow_params = request.params.get("params").cloned().unwrap_or(Value::Null);
6982 if let Some(identity_runtime) = &identity_runtime
6983 && let Err(err) = identity_runtime.materialize_all_required().await
6984 {
6985 return internal_error(
6986 response_id,
6987 format!("identity-first flow materialization failed: {err}"),
6988 );
6989 }
6990 match Box::pin(runtime.handle().run_flow(flow_id, flow_params)).await {
6991 Ok(run_id) => response_value(
6992 response_id,
6993 Some(serde_json::json!({ "run_id": run_id.to_string() })),
6994 None,
6995 ),
6996 Err(err) => invalid_params(response_id, format!("run_flow failed: {err}")),
6997 }
6998 }
6999 "mobkit/spawn_helper" => {
7000 let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
7001 else {
7002 return invalid_params(response_id, "agent_identity required");
7003 };
7004 let Some(task) = request.params.get("task").and_then(Value::as_str) else {
7005 return invalid_params(response_id, "task required");
7006 };
7007 let options = match parse_console_helper_options(request.params.get("options")) {
7008 Ok(opts) => opts,
7009 Err(msg) => return invalid_params(response_id, msg),
7010 };
7011 let handle = runtime.handle();
7012 match Box::pin(handle.spawn_helper(
7013 crate::member_comms_id::mob_member_id(agent_identity),
7014 task,
7015 options,
7016 ))
7017 .await
7018 {
7019 Ok(result) => {
7020 response_value(
7025 response_id,
7026 Some(serde_json::json!({
7027 "output": result.output,
7028 "tokens_used": result.tokens_used,
7029 })),
7030 None,
7031 )
7032 }
7033 Err(err) => internal_error(response_id, format!("spawn_helper failed: {err}")),
7034 }
7035 }
7036 "mobkit/fork_helper" => {
7037 let Some(source) = request
7038 .params
7039 .get("source_member_id")
7040 .and_then(Value::as_str)
7041 else {
7042 return invalid_params(response_id, "source_member_id required");
7043 };
7044 let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
7045 else {
7046 return invalid_params(response_id, "agent_identity required");
7047 };
7048 let Some(task) = request.params.get("task").and_then(Value::as_str) else {
7049 return invalid_params(response_id, "task required");
7050 };
7051 let fork_context = match request.params.get("fork_context") {
7052 Some(v) if !v.is_null() => {
7053 match serde_json::from_value::<meerkat_mob::launch::ForkContext>(v.clone()) {
7054 Ok(ctx) => ctx,
7055 Err(err) => {
7056 return invalid_params(
7057 response_id,
7058 format!("invalid fork_context: {err}"),
7059 );
7060 }
7061 }
7062 }
7063 _ => meerkat_mob::launch::ForkContext::default(),
7064 };
7065 let options = match parse_console_helper_options(request.params.get("options")) {
7066 Ok(opts) => opts,
7067 Err(msg) => return invalid_params(response_id, msg),
7068 };
7069 let handle = runtime.handle();
7070 match Box::pin(handle.fork_helper(
7071 &crate::member_comms_id::mob_member_id(source),
7072 crate::member_comms_id::mob_member_id(agent_identity),
7073 task,
7074 fork_context,
7075 options,
7076 ))
7077 .await
7078 {
7079 Ok(result) => {
7080 response_value(
7084 response_id,
7085 Some(serde_json::json!({
7086 "output": result.output,
7087 "tokens_used": result.tokens_used,
7088 })),
7089 None,
7090 )
7091 }
7092 Err(err) => internal_error(response_id, format!("fork_helper failed: {err}")),
7093 }
7094 }
7095 "mobkit/attach_existing_session" => {
7096 let Some(role) = request.params.get("role").and_then(Value::as_str) else {
7097 return invalid_params(response_id, "role required");
7098 };
7099 let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
7100 else {
7101 return invalid_params(response_id, "agent_identity required");
7102 };
7103 let Some(session_id_str) = request.params.get("session_id").and_then(Value::as_str)
7104 else {
7105 return invalid_params(response_id, "session_id required");
7106 };
7107 let bridge_session_id = match meerkat_core::types::SessionId::parse(session_id_str) {
7108 Ok(s) => s,
7109 Err(_) => return invalid_params(response_id, "invalid session_id format"),
7110 };
7111 let mid = crate::member_comms_id::mob_member_id(agent_identity);
7112 let spec = SpawnMemberSpec::new(ProfileName::from(role), mid.clone())
7113 .with_launch_mode(MemberLaunchMode::Resume { bridge_session_id });
7114 let handle = runtime.handle();
7115 match Box::pin(handle.spawn_spec(spec)).await {
7116 Ok(_) => match handle.member_status(&mid).await {
7117 Ok(snapshot) => response_value(
7118 response_id,
7119 Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
7120 None,
7121 ),
7122 Err(err) => internal_error(
7123 response_id,
7124 format!("attach_existing_session status lookup failed: {err}"),
7125 ),
7126 },
7127 Err(err) => internal_error(
7128 response_id,
7129 format!("attach_existing_session failed: {err}"),
7130 ),
7131 }
7132 }
7133 "mobkit/cross_mob/wire_local" => {
7134 handle_console_wire_local(runtime, &request.params, response_id, true).await
7135 }
7136 "mobkit/cross_mob/unwire_local" => {
7137 handle_console_wire_local(runtime, &request.params, response_id, false).await
7138 }
7139 "mobkit/peer_pubkey" => match gateway_peer_keys {
7140 Some(keys) => response_value(
7141 response_id,
7142 Some(serde_json::json!({ "pubkey_b64": keys.pubkey_b64() })),
7143 None,
7144 ),
7145 None => response_value(
7146 response_id,
7147 None,
7148 Some(JsonRpcError {
7149 code: -32004,
7150 message: "gateway has no signing keypair configured".to_string(),
7151 data: None,
7152 }),
7153 ),
7154 },
7155 "mobkit/cross_mob/peer_info" => {
7156 let member_id = request.params.get("member_id").and_then(Value::as_str);
7157 match member_id {
7158 Some(mid) if !mid.is_empty() => {
7159 let handle = runtime.handle();
7160 let mob_id = handle.mob_id().to_string();
7161 let meerkat_id = crate::member_comms_id::mob_member_id(mid);
7162 match handle.get_member(&meerkat_id).await {
7163 Err(err) => response_value(
7164 response_id,
7165 None,
7166 Some(JsonRpcError {
7167 code: -32000,
7168 message: format!("member lookup failed: {err}"),
7169 data: None,
7170 }),
7171 ),
7172 Ok(Some(entry)) => match entry.peer_id() {
7173 Some(peer_id) => {
7174 let comms_name = format!("{}/{}/{}", mob_id, entry.role, mid);
7175 let address = format!("inproc://{comms_name}");
7176 response_value(
7177 response_id,
7178 Some(serde_json::json!({
7179 "member_id": mid,
7180 "mob_id": mob_id,
7181 "comms_name": comms_name,
7182 "peer_id": peer_id,
7183 "address": address,
7184 })),
7185 None,
7186 )
7187 }
7188 None => response_value(
7189 response_id,
7190 None,
7191 Some(JsonRpcError {
7192 code: -32000,
7193 message: format!("member {mid:?} has no comms runtime"),
7194 data: None,
7195 }),
7196 ),
7197 },
7198 Ok(None) => response_value(
7199 response_id,
7200 None,
7201 Some(JsonRpcError {
7202 code: -32000,
7203 message: format!("member {mid:?} not found"),
7204 data: None,
7205 }),
7206 ),
7207 }
7208 }
7209 _ => invalid_params(response_id, "member_id required".to_string()),
7210 }
7211 }
7212 "mobkit/cross_mob/directory" => {
7213 let entries: Vec<Value> = contact_directory
7214 .map(|dir| {
7215 dir.list()
7216 .into_iter()
7217 .filter_map(|e| serde_json::to_value(e).ok())
7218 .collect()
7219 })
7220 .unwrap_or_default();
7221 response_value(
7222 response_id,
7223 Some(serde_json::json!({ "mobs": entries })),
7224 None,
7225 )
7226 }
7227 method
7228 if matches!(
7229 method,
7230 "mobkit/mob_labels/set"
7231 | "mobkit/mob_labels/get"
7232 | "mobkit/mob_labels/delete"
7233 | "mobkit/run_labels/set"
7234 | "mobkit/run_labels/get"
7235 | "mobkit/run_labels/delete",
7236 ) =>
7237 {
7238 dispatch_label_method(
7239 method,
7240 metadata_table.as_deref(),
7241 runtime.handle().mob_id().as_str(),
7242 response_id,
7243 &request.params,
7244 )
7245 .await
7246 }
7247 _ => response_value(
7248 response_id,
7249 None,
7250 Some(JsonRpcError {
7251 code: -32601,
7252 message: "Method not found".to_string(),
7253 data: None,
7254 }),
7255 ),
7256 }
7257}
7258
7259async fn dispatch_label_method(
7266 method: &str,
7267 metadata_table: Option<&RuntimeMetadataTable>,
7268 mob_id: &str,
7269 response_id: Value,
7270 params: &Value,
7271) -> Value {
7272 let Some(table) = metadata_table else {
7273 return invalid_params(
7274 response_id,
7275 "metadata table not configured for this runtime",
7276 );
7277 };
7278
7279 let scope = match method {
7280 "mobkit/mob_labels/set" | "mobkit/mob_labels/get" | "mobkit/mob_labels/delete" => {
7281 MetadataScope::Mob(mob_id.to_string())
7282 }
7283 _ => match crate::runtime::parse_run_id_param(params) {
7284 Ok(run_id) => MetadataScope::Run(mob_id.to_string(), run_id.to_string()),
7285 Err(message) => return invalid_params(response_id, message),
7286 },
7287 };
7288
7289 let outcome = match method {
7290 "mobkit/mob_labels/set" | "mobkit/run_labels/set" => {
7291 crate::runtime::dispatch_labels_set(table, scope, params).await
7292 }
7293 "mobkit/mob_labels/get" | "mobkit/run_labels/get" => {
7294 crate::runtime::dispatch_labels_get(table, scope).await
7295 }
7296 "mobkit/mob_labels/delete" | "mobkit/run_labels/delete" => {
7297 crate::runtime::dispatch_labels_delete(table, scope).await
7298 }
7299 _ => unreachable!("dispatch_label_method called with non-label method: {method}"),
7300 };
7301
7302 match outcome {
7303 crate::runtime::LabelRpcResult::Accepted => response_value(
7304 response_id,
7305 Some(serde_json::json!({"accepted": true})),
7306 None,
7307 ),
7308 crate::runtime::LabelRpcResult::Labels(labels) => response_value(
7309 response_id,
7310 Some(serde_json::json!({"labels": labels_to_json_value(&labels)})),
7311 None,
7312 ),
7313 crate::runtime::LabelRpcResult::InvalidParams(message) => {
7314 invalid_params(response_id, message)
7315 }
7316 }
7317}
7318
7319async fn handle_console_wire_local(
7328 runtime: &MobRuntime,
7329 params: &Value,
7330 response_id: Value,
7331 wire: bool,
7332) -> Value {
7333 let local = params.get("local_member_id").and_then(Value::as_str);
7334 let comms_name = params.get("remote_comms_name").and_then(Value::as_str);
7335 let peer_id = params.get("remote_peer_id").and_then(Value::as_str);
7336 let addr = params.get("remote_address").and_then(Value::as_str);
7337
7338 let remote_pubkey = match params.get("remote_pubkey_b64") {
7339 None => None,
7340 Some(v) if v.is_null() => None,
7341 Some(v) => match v.as_str() {
7342 Some(s) if !s.is_empty() => match crate::auth::peer_keys::decode_pubkey_b64(s) {
7343 Ok(bytes) => Some(bytes),
7344 Err(err) => {
7345 return invalid_params(response_id, format!("remote_pubkey_b64: {err}"));
7346 }
7347 },
7348 _ => None,
7349 },
7350 };
7351
7352 let (local_id, cname, pid, address) = match (local, comms_name, peer_id, addr) {
7353 (Some(l), Some(c), Some(p), Some(a))
7354 if !l.is_empty() && !c.is_empty() && !p.is_empty() && !a.is_empty() =>
7355 {
7356 (l, c, p, a)
7357 }
7358 _ => {
7359 return invalid_params(
7360 response_id,
7361 "local_member_id, remote_comms_name, remote_peer_id, and remote_address required",
7362 );
7363 }
7364 };
7365
7366 let is_inproc = address.starts_with("inproc://");
7367 let spec_result = match (is_inproc, remote_pubkey) {
7368 (true, None) => TrustedPeerDescriptor::test_only_unsigned(cname, pid, address),
7369 (true, Some(bytes)) => {
7370 TrustedPeerDescriptor::unsigned_with_pubkey(cname, pid, bytes, address)
7371 }
7372 (false, None) => {
7373 return invalid_params(
7374 response_id,
7375 "remote_pubkey_b64 is required for non-inproc transports",
7376 );
7377 }
7378 (false, Some(bytes)) => {
7379 if bytes == [0u8; 32] {
7380 return invalid_params(
7381 response_id,
7382 "remote_pubkey_b64 must be non-zero for non-inproc transports",
7383 );
7384 }
7385 TrustedPeerDescriptor::unsigned_with_pubkey(cname, pid, bytes, address)
7386 }
7387 };
7388
7389 let spec = match spec_result {
7390 Ok(spec) => spec,
7391 Err(err) => {
7392 return invalid_params(response_id, format!("invalid peer spec: {err}"));
7393 }
7394 };
7395
7396 let result = if wire {
7397 runtime
7398 .handle()
7399 .wire(
7400 crate::member_comms_id::mob_member_id(local_id),
7401 PeerTarget::External(spec),
7402 )
7403 .await
7404 } else {
7405 runtime
7406 .handle()
7407 .unwire(
7408 crate::member_comms_id::mob_member_id(local_id),
7409 PeerTarget::External(spec),
7410 )
7411 .await
7412 };
7413
7414 let action = if wire { "wire_local" } else { "unwire_local" };
7415 match result {
7416 Ok(()) => response_value(
7417 response_id,
7418 Some(serde_json::json!({
7419 "accepted": true,
7420 "local_member_id": local_id,
7421 "remote_comms_name": cname,
7422 })),
7423 None,
7424 ),
7425 Err(err) => internal_error(response_id, format!("cross_mob/{action} failed: {err}")),
7426 }
7427}
7428
7429async fn build_live_snapshot(
7430 runtime: &MobRuntime,
7431 config_module_ids: &[String],
7432 console_events: Option<&ConsoleEventStore>,
7433 visibility_policy: &dyn ConsoleVisibilityPolicy,
7434 read_model: &ConsoleSnapshotReadModel,
7435) -> ConsoleLiveSnapshot {
7436 let read_model_state = Box::pin(read_model.snapshot(runtime)).await;
7437 let running = read_model_state.running.unwrap_or(true);
7438 let mut members = read_model_state.primary_members.clone();
7445 if visibility_policy.include_implicit_delegate_members() {
7446 for group in &read_model_state.delegate_member_groups {
7447 members.extend(group.iter().cloned());
7448 }
7449 }
7450 dedupe_console_members_by_identity(&mut members);
7451 members.retain(|member| {
7452 visibility_policy.member_visible(member)
7453 && visibility_policy
7454 .identity_visible(&console_identity_record_from_console_member(member))
7455 });
7456
7457 let loaded_modules = if config_module_ids.is_empty() {
7461 let mut mods: Vec<String> = members
7462 .iter()
7463 .filter(|member| member.state != MEMBER_STATE_RETIRING)
7464 .map(|member| member.agent_identity.clone())
7465 .collect();
7466 mods.sort();
7467 mods
7468 } else {
7469 let mut mods = config_module_ids.to_vec();
7470 mods.sort();
7471 mods
7472 };
7473
7474 let agents = members
7475 .iter()
7476 .map(|member| async move {
7477 let console_identity = console_member_console_identity(member);
7478 let label = member
7479 .labels
7480 .get("display_name")
7481 .cloned()
7482 .unwrap_or_else(|| member.agent_identity.clone());
7483 let watched = member
7484 .labels
7485 .get("console_watched")
7486 .map(|value: &String| value == "true");
7487 let alert_level = member
7488 .labels
7489 .get("console_alert_level")
7490 .filter(|value: &&String| matches!(value.as_str(), "elevated" | "critical"))
7491 .cloned();
7492 let degraded = member
7493 .labels
7494 .get("console_degraded")
7495 .map(|value: &String| value == "true");
7496 let degraded_reason = member.labels.get("console_degraded_reason").cloned();
7497 let response_phase = match console_events {
7498 Some(store) => store.response_phase_for_identity(console_identity).await,
7499 None => None,
7500 };
7501 ConsoleAgentLiveSnapshot {
7502 agent_id: member.agent_identity.clone(),
7503 member_id: member.agent_identity.clone(),
7504 label,
7505 kind: "meerkat".to_string(),
7506 identity: Some(console_identity.to_string()),
7507 role: Some(member.role.clone()),
7508 state: Some(member.state.clone()),
7509 session_id: member.session_id.clone(),
7510 model_capabilities: member.model_capabilities.clone(),
7511 response_phase,
7512 watched,
7513 alert_level,
7514 degraded,
7515 degraded_reason,
7516 }
7517 })
7518 .collect::<Vec<_>>();
7519 let mut agents = join_all(agents).await;
7520 agents.sort_by(|left, right| left.label.cmp(&right.label));
7521 ConsoleLiveSnapshot::new(
7522 Some(runtime.handle().mob_id().to_string()),
7523 running,
7524 loaded_modules,
7525 agents,
7526 members,
7527 true,
7528 )
7529}
7530
7531async fn collect_console_snapshot_read_model(
7532 runtime: &MobRuntime,
7533) -> ConsoleSnapshotReadModelState {
7534 let handle = runtime.handle();
7535 let mut state = ConsoleSnapshotReadModelState {
7536 running: Some(matches!(
7537 handle.status_observation_snapshot(),
7538 MobState::Creating | MobState::Running
7539 )),
7540 ..ConsoleSnapshotReadModelState::default()
7541 };
7542 collect_console_session_index_for_handle(&handle, &mut state).await;
7543 let registered_labels = runtime.console_identity_labels().await;
7544
7545 let (primary_members, _primary_owner_index) =
7551 project_console_members_from_handle(&handle, None, None, &state, ®istered_labels).await;
7552 state.primary_members = primary_members;
7553
7554 let Some(mcp_state) = runtime.agent_mob_mcp_state() else {
7555 return state;
7556 };
7557 let primary_mob_id = handle.mob_id().to_string();
7558 let mut processed = BTreeSet::from([primary_mob_id]);
7559 let mut delegate_groups: Vec<Vec<ConsoleMember>> = Vec::new();
7560 loop {
7561 let mut progressed = false;
7562 for (mob_id, delegate_handle) in Box::pin(mcp_state.mob_handles_snapshot())
7563 .await
7564 .unwrap_or_default()
7565 {
7566 if processed.contains(mob_id.as_str()) {
7567 continue;
7568 }
7569 let Some(owner_authority) = delegate_handle.owner_bridge_session_lifecycle_authority()
7572 else {
7573 processed.insert(mob_id.to_string());
7574 continue;
7575 };
7576 let owner_session_id = owner_authority.bridge_session_id.to_string();
7577 let Some(host_identity) = state
7578 .session_owner_by_id
7579 .get(owner_session_id.as_str())
7580 .cloned()
7581 else {
7582 continue;
7583 };
7584 collect_console_session_index_for_handle(&delegate_handle, &mut state).await;
7585 let (delegate_members, _delegate_owner_index) = project_console_members_from_handle(
7586 &delegate_handle,
7587 Some(&host_identity),
7588 Some(mob_id.as_str()),
7589 &state,
7590 ®istered_labels,
7591 )
7592 .await;
7593 delegate_groups.push(delegate_members);
7594 processed.insert(mob_id.to_string());
7595 progressed = true;
7596 }
7597 if !progressed {
7598 break;
7599 }
7600 }
7601 state.delegate_member_groups = delegate_groups;
7602 state
7603}
7604
7605async fn collect_console_session_index_for_handle(
7606 handle: &MobHandle,
7607 state: &mut ConsoleSnapshotReadModelState,
7608) {
7609 for entry in handle.list_members_observation_snapshot().await {
7610 let identity =
7613 crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
7614 let Some(session_id) = handle
7615 .resolve_bridge_session_id_observation(&entry.agent_identity)
7616 .await
7617 .map(|session_id| session_id.to_string())
7618 else {
7619 state.session_id_by_identity.remove(&identity);
7620 continue;
7621 };
7622 state
7623 .session_owner_by_id
7624 .insert(session_id.clone(), identity.clone());
7625 state.session_id_by_identity.insert(identity, session_id);
7626 }
7627}
7628
7629fn apply_console_visibility_policy(
7630 snapshot: &mut ConsoleLiveSnapshot,
7631 visibility_policy: &dyn ConsoleVisibilityPolicy,
7632) {
7633 let mut hidden = BTreeSet::new();
7634 snapshot.members.retain(|member| {
7635 let visible = visibility_policy.member_visible(member)
7636 && visibility_policy
7637 .identity_visible(&console_identity_record_from_console_member(member));
7638 if !visible {
7639 hidden.insert(member.agent_identity.clone());
7640 }
7641 visible
7642 });
7643 snapshot
7644 .agents
7645 .retain(|agent| !hidden.contains(&agent.agent_id));
7646 snapshot
7647 .loaded_modules
7648 .retain(|module_id| !hidden.contains(module_id));
7649}
7650
7651async fn reset_all_live_console_agents(
7652 runtime: &MobRuntime,
7653 console_events: Option<&ConsoleEventStore>,
7654 console_aggregator: Option<&MobKitConsoleAggregator>,
7655 identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
7656 visibility_policy: &dyn ConsoleVisibilityPolicy,
7657) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
7658 let read_model = ConsoleSnapshotReadModel::default();
7659 *read_model.inner.write().await = Box::pin(collect_console_snapshot_read_model(runtime)).await;
7660 read_model
7663 .primed
7664 .store(true, std::sync::atomic::Ordering::Release);
7665 let snapshot = Box::pin(build_live_snapshot(
7666 runtime,
7667 &[],
7668 console_events,
7669 visibility_policy,
7670 &read_model,
7671 ))
7672 .await;
7673 let raw_snapshot = Box::pin(build_live_snapshot(
7674 runtime,
7675 &[],
7676 console_events,
7677 &crate::console_aggregator::AllowAllConsoleVisibilityPolicy,
7678 &read_model,
7679 ))
7680 .await;
7681 let identity_runtime_statuses = if let Some(identity_runtime) = identity_runtime {
7682 identity_runtime.statuses().await
7683 } else {
7684 Vec::new()
7685 };
7686 let identity_by_runtime_member_id = identity_runtime_statuses
7687 .iter()
7688 .filter_map(|status| {
7689 status
7690 .agent_runtime_id
7691 .as_ref()
7692 .map(|runtime_id| (runtime_id.as_str().to_string(), status.identity.to_string()))
7693 })
7694 .collect::<BTreeMap<_, _>>();
7695 let mut durable_identity_runtime_identities = identity_runtime_statuses
7696 .iter()
7697 .filter(|status| identity_status_visible_to_console(visibility_policy, status))
7698 .map(|status| status.identity.to_string())
7699 .collect::<BTreeSet<_>>();
7700 let mut main_identities = BTreeSet::new();
7701 let mut runtime_member_id_by_identity = BTreeMap::new();
7702 let mut runtime_member_ids_by_identity: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
7703 let mut session_id_by_identity_runtime_member: BTreeMap<(String, String), Option<String>> =
7704 BTreeMap::new();
7705 let mut live_alias_by_runtime_member_id: BTreeMap<String, (String, Option<String>)> =
7706 BTreeMap::new();
7707 let mut visible_runtime_member_ids = BTreeSet::new();
7708 let mut duplicate_live_identities = BTreeSet::new();
7709 let mut delegate_members = BTreeSet::new();
7710 for member in snapshot.members {
7711 if member.state == MEMBER_STATE_RETIRING {
7712 continue;
7713 }
7714 if let Some(source_mob_id) = member.labels.get("source_mob_id").cloned() {
7715 delegate_members.insert((source_mob_id, member.agent_identity));
7716 } else {
7717 let identity = member
7718 .labels
7719 .get("agent_identity")
7720 .filter(|value| !value.trim().is_empty())
7721 .cloned()
7722 .or_else(|| {
7723 identity_by_runtime_member_id
7724 .get(&member.agent_identity)
7725 .cloned()
7726 })
7727 .unwrap_or_else(|| member.agent_identity.clone());
7728 if let Some(existing) = runtime_member_id_by_identity.get(&identity)
7729 && existing != &member.agent_identity
7730 {
7731 duplicate_live_identities.insert(identity.clone());
7732 }
7733 runtime_member_ids_by_identity
7734 .entry(identity.clone())
7735 .or_default()
7736 .insert(member.agent_identity.clone());
7737 session_id_by_identity_runtime_member.insert(
7738 (identity.clone(), member.agent_identity.clone()),
7739 member.session_id.clone(),
7740 );
7741 live_alias_by_runtime_member_id.insert(
7742 member.agent_identity.clone(),
7743 (identity.clone(), member.session_id.clone()),
7744 );
7745 visible_runtime_member_ids.insert(member.agent_identity.clone());
7746 runtime_member_id_by_identity
7747 .entry(identity.clone())
7748 .or_insert(member.agent_identity);
7749 main_identities.insert(identity);
7750 }
7751 }
7752 let mut raw_runtime_member_ids_by_identity: BTreeMap<String, BTreeSet<String>> =
7753 BTreeMap::new();
7754 let mut raw_session_id_by_identity_runtime_member: BTreeMap<(String, String), Option<String>> =
7755 BTreeMap::new();
7756 let mut raw_live_alias_by_runtime_member_id: BTreeMap<String, (String, Option<String>)> =
7757 BTreeMap::new();
7758 for member in raw_snapshot.members {
7759 if member.state == MEMBER_STATE_RETIRING || member.labels.contains_key("source_mob_id") {
7760 continue;
7761 }
7762 let identity = member
7763 .labels
7764 .get("agent_identity")
7765 .filter(|value| !value.trim().is_empty())
7766 .cloned()
7767 .or_else(|| {
7768 identity_by_runtime_member_id
7769 .get(&member.agent_identity)
7770 .cloned()
7771 })
7772 .unwrap_or_else(|| member.agent_identity.clone());
7773 raw_runtime_member_ids_by_identity
7774 .entry(identity.clone())
7775 .or_default()
7776 .insert(member.agent_identity.clone());
7777 raw_session_id_by_identity_runtime_member.insert(
7778 (identity.clone(), member.agent_identity.clone()),
7779 member.session_id.clone(),
7780 );
7781 raw_live_alias_by_runtime_member_id
7782 .insert(member.agent_identity, (identity, member.session_id));
7783 }
7784 durable_identity_runtime_identities.retain(|identity| {
7785 identity_runtime_statuses
7786 .iter()
7787 .find(|status| status.identity.as_str() == identity)
7788 .and_then(|status| status.agent_runtime_id.as_ref())
7789 .is_none_or(|runtime_id| {
7790 let runtime_id = runtime_id.as_str();
7791 !raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
7792 || visible_runtime_member_ids.contains(runtime_id)
7793 })
7794 });
7795 let current_main_identities = main_identities.clone();
7796 let baseline_specs = runtime.baseline_member_specs().await;
7797 let baseline_identities = baseline_specs
7798 .iter()
7799 .filter(|spec| baseline_spec_visible_to_console(visibility_policy, spec))
7800 .map(|spec| spec.identity.to_string())
7801 .collect::<BTreeSet<_>>();
7802 main_identities.extend(baseline_identities.iter().cloned());
7803 main_identities.extend(durable_identity_runtime_identities.iter().cloned());
7804
7805 let mut retired_delegates = Vec::new();
7806 let mut reset_main = Vec::new();
7807 let mut retired_delegate_details = Vec::new();
7808 let mut reset_details = Vec::new();
7809 let mut failures = Vec::new();
7810 let mut warnings = Vec::new();
7811
7812 for identity in &main_identities {
7813 let parsed_identity = crate::identity_first::AgentIdentity::parse(identity).ok();
7814 let registered_status = if let (Some(identity_runtime), Some(parsed_identity)) =
7815 (identity_runtime, parsed_identity.as_ref())
7816 {
7817 identity_runtime.status(parsed_identity).await.ok()
7818 } else {
7819 None
7820 };
7821 let baseline_identity_runtime_registered = registered_status.is_some();
7822 if baseline_identities.contains(identity)
7823 && !current_main_identities.contains(identity)
7824 && !baseline_identity_runtime_registered
7825 {
7826 continue;
7827 }
7828 let registered_runtime_id = registered_status
7829 .as_ref()
7830 .and_then(|status| status.agent_runtime_id.as_ref())
7831 .map(crate::identity_first::AgentRuntimeId::as_str);
7832 let registered_visible = registered_runtime_id
7833 .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
7834 let registered_hidden = registered_runtime_id.is_some_and(|runtime_id| {
7835 raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
7836 && !visible_runtime_member_ids.contains(runtime_id)
7837 });
7838 if registered_hidden {
7839 continue;
7840 }
7841 if duplicate_live_identities.contains(identity) && !registered_visible {
7842 failures.push(json!({
7843 "identity": identity,
7844 "error": "ambiguous live identity alias",
7845 }));
7846 continue;
7847 }
7848 if let Some(status) = registered_status.as_ref() {
7849 if let Some(registered_runtime_id) = registered_runtime_id
7850 && let Some((live_identity, _live_session_id)) =
7851 raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
7852 && live_identity != identity
7853 {
7854 failures.push(json!({
7855 "identity": identity,
7856 "error": format!(
7857 "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
7858 ),
7859 "kind": "stale_live_identity_alias",
7860 }));
7861 continue;
7862 }
7863 if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(identity) {
7864 if !registered_runtime_id
7865 .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
7866 {
7867 failures.push(json!({
7868 "identity": identity,
7869 "error": format!(
7870 "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
7871 registered_runtime_id.unwrap_or("<none>"),
7872 live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
7873 ),
7874 "kind": "stale_live_identity_alias",
7875 }));
7876 continue;
7877 }
7878 if let Some(registered_runtime_id) = registered_runtime_id
7879 && let Some(registered_session_id) =
7880 status.session_id.as_ref().map(ToString::to_string)
7881 && let Some(Some(live_session_id)) = raw_session_id_by_identity_runtime_member
7882 .get(&(identity.clone(), registered_runtime_id.to_string()))
7883 && live_session_id != ®istered_session_id
7884 {
7885 failures.push(json!({
7886 "identity": identity,
7887 "error": format!(
7888 "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}"
7889 ),
7890 "kind": "stale_live_identity_alias",
7891 }));
7892 continue;
7893 }
7894 }
7895 if baseline_identities.contains(identity)
7896 && !identity_runtime
7897 .is_some_and(|identity_runtime| identity_runtime.has_session_bridge())
7898 {
7899 failures.push(json!({
7900 "identity": identity,
7901 "error": "reset requires an identity runtime with a session bridge",
7902 "kind": "identity_reset_requires_session_bridge",
7903 }));
7904 }
7905 continue;
7906 }
7907
7908 let runtime_member_id = runtime_member_id_by_identity
7909 .get(identity)
7910 .map(String::as_str)
7911 .unwrap_or(identity.as_str());
7912 if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
7913 && bound_identity != identity
7914 {
7915 failures.push(json!({
7916 "identity": identity,
7917 "error": format!(
7918 "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
7919 ),
7920 "kind": "stale_live_identity_alias",
7921 }));
7922 }
7923 }
7924
7925 if !failures.is_empty() {
7926 return Ok(json!({
7927 "reset": reset_main,
7928 "retired_delegates": retired_delegates,
7929 "reset_details": reset_details,
7930 "retired_delegate_details": retired_delegate_details,
7931 "warnings": warnings,
7932 "failed": failures,
7933 "startup_history": Value::Null,
7934 }));
7935 }
7936
7937 if let Some(state) = runtime.agent_mob_mcp_state() {
7938 for (mob_id, identity) in delegate_members {
7939 match Box::pin(state.handle_for(&MobId::from(mob_id.as_str()))).await {
7940 Ok(handle) => {
7941 match retire_console_member(
7942 &handle,
7943 &crate::member_comms_id::mob_member_id(identity.as_str()),
7944 )
7945 .await
7946 {
7947 Ok(()) => {
7948 let detail = json!({
7949 "identity": identity,
7950 "mob_id": mob_id,
7951 });
7952 retired_delegates.push(detail.clone());
7953 retired_delegate_details.push(detail);
7954 }
7955 Err(err) => failures.push(json!({
7956 "identity": identity,
7957 "mob_id": mob_id,
7958 "error": err,
7959 })),
7960 }
7961 }
7962 Err(err) => failures.push(json!({
7963 "identity": identity,
7964 "mob_id": mob_id,
7965 "error": err.to_string(),
7966 })),
7967 }
7968 }
7969 } else if let Some(aggregator) = console_aggregator {
7970 let identities = delegate_members
7971 .into_iter()
7972 .map(|(_, identity)| identity)
7973 .collect::<BTreeSet<_>>();
7974 for identity in identities {
7975 match Box::pin(aggregator.retire_identity(&identity)).await {
7976 Ok(true) => {
7977 let detail = json!({ "identity": identity });
7978 retired_delegates.push(detail.clone());
7979 retired_delegate_details.push(detail);
7980 }
7981 Ok(false) => failures.push(json!({
7982 "identity": identity,
7983 "error": "unknown identity",
7984 })),
7985 Err(err) => failures.push(json!({
7986 "identity": identity,
7987 "error": err.to_string(),
7988 })),
7989 }
7990 }
7991 }
7992
7993 let handle = runtime.handle();
7994 for spec in baseline_specs {
7995 let identity = spec.identity.to_string();
7996 if !baseline_spec_visible_to_console(visibility_policy, &spec) {
7997 continue;
7998 }
7999 if current_main_identities.contains(&identity) {
8000 continue;
8001 }
8002 if let Some(identity_runtime) = identity_runtime
8003 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8004 && identity_runtime.status(&parsed_identity).await.is_ok()
8005 {
8006 continue;
8007 }
8008 match handle.ensure_member(spec).await {
8009 Ok(_outcome) => {
8010 if let Some(store) = console_events {
8011 store
8012 .record_lifecycle(
8013 &identity,
8014 "identity_reset",
8015 json!({ "scope": "reset_all", "restored": true }),
8016 )
8017 .await;
8018 }
8019 reset_main.push(identity.clone());
8020 reset_details.push(json!({ "identity": identity }));
8021 }
8022 Err(err) => failures.push(json!({
8023 "identity": identity,
8024 "error": err.to_string(),
8025 })),
8026 }
8027 }
8028 for identity in main_identities {
8029 let baseline_identity_runtime_registered = if let Some(identity_runtime) = identity_runtime
8030 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8031 {
8032 identity_runtime.status(&parsed_identity).await.is_ok()
8033 } else {
8034 false
8035 };
8036 if baseline_identities.contains(&identity)
8037 && !current_main_identities.contains(&identity)
8038 && !baseline_identity_runtime_registered
8039 {
8040 continue;
8041 }
8042 let registered_status = if let Some(identity_runtime) = identity_runtime
8043 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8044 {
8045 identity_runtime.status(&parsed_identity).await.ok()
8046 } else {
8047 None
8048 };
8049 let registered_runtime_id = registered_status
8050 .as_ref()
8051 .and_then(|status| status.agent_runtime_id.as_ref())
8052 .map(crate::identity_first::AgentRuntimeId::as_str);
8053 let registered_visible = registered_runtime_id
8054 .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
8055 let registered_hidden = registered_runtime_id.is_some_and(|runtime_id| {
8056 raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
8057 && !visible_runtime_member_ids.contains(runtime_id)
8058 });
8059 if registered_hidden {
8060 continue;
8061 }
8062 if duplicate_live_identities.contains(&identity) && !registered_visible {
8063 failures.push(json!({
8064 "identity": identity,
8065 "error": "ambiguous live identity alias",
8066 }));
8067 continue;
8068 }
8069 if baseline_identities.contains(&identity) {
8070 if let Some(identity_runtime) = identity_runtime
8071 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8072 && let Ok(status) = identity_runtime.status(&parsed_identity).await
8073 {
8074 let registered_runtime_id = status
8075 .agent_runtime_id
8076 .as_ref()
8077 .map(crate::identity_first::AgentRuntimeId::as_str);
8078 if let Some(registered_runtime_id) = registered_runtime_id
8079 && let Some((live_identity, _live_session_id)) =
8080 raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
8081 && live_identity != &identity
8082 {
8083 failures.push(json!({
8084 "identity": identity,
8085 "error": format!(
8086 "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
8087 ),
8088 "kind": "stale_live_identity_alias",
8089 }));
8090 continue;
8091 }
8092 if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(&identity) {
8093 if !registered_runtime_id
8094 .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
8095 {
8096 failures.push(json!({
8097 "identity": identity,
8098 "error": format!(
8099 "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
8100 registered_runtime_id.unwrap_or("<none>"),
8101 live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
8102 ),
8103 "kind": "stale_live_identity_alias",
8104 }));
8105 continue;
8106 }
8107 if let Some(registered_runtime_id) = registered_runtime_id
8108 && let Some(registered_session_id) =
8109 status.session_id.as_ref().map(ToString::to_string)
8110 && let Some(Some(live_session_id)) =
8111 raw_session_id_by_identity_runtime_member
8112 .get(&(identity.clone(), registered_runtime_id.to_string()))
8113 && live_session_id != ®istered_session_id
8114 {
8115 failures.push(json!({
8116 "identity": identity,
8117 "error": format!(
8118 "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}"
8119 ),
8120 "kind": "stale_live_identity_alias",
8121 }));
8122 continue;
8123 }
8124 }
8125 if !identity_runtime.has_session_bridge() {
8126 failures.push(json!({
8127 "identity": identity,
8128 "error": "reset requires an identity runtime with a session bridge",
8129 "kind": "identity_reset_requires_session_bridge",
8130 }));
8131 continue;
8132 }
8133 match identity_runtime.reset(&parsed_identity).await {
8134 Ok(record) => {
8135 let cleanup_warning = json!({
8136 "identity": identity,
8137 "kind": "stale_member_cleanup_skipped_after_identity_reset",
8138 "message": "reset published the new generation without retiring stale live mob members; identity control calls reject stale runtime ids",
8139 });
8140 warnings.push(cleanup_warning);
8141 reset_details.push(json!({
8142 "identity": identity,
8143 "cleanup_warning": warnings.last().cloned(),
8144 }));
8145 reset_main.push(identity);
8146 if let Some(store) = console_events {
8147 store
8148 .record_lifecycle(
8149 parsed_identity.as_str(),
8150 "identity_reset",
8151 json!({
8152 "scope": "reset_all",
8153 "generation": record.generation.get(),
8154 "checkpoint_version": record.checkpoint_version.get(),
8155 "cleanup_warning": warnings.last().cloned(),
8156 }),
8157 )
8158 .await;
8159 }
8160 }
8161 Err(err) => failures.push(json!({
8162 "identity": identity,
8163 "error": err.to_string(),
8164 })),
8165 }
8166 continue;
8167 }
8168 let runtime_member_id = runtime_member_id_by_identity
8169 .get(&identity)
8170 .map(String::as_str)
8171 .unwrap_or(identity.as_str());
8172 if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
8173 && bound_identity != &identity
8174 {
8175 failures.push(json!({
8176 "identity": identity,
8177 "error": format!(
8178 "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
8179 ),
8180 "kind": "stale_live_identity_alias",
8181 }));
8182 continue;
8183 }
8184 match Box::pin(respawn_console_member(
8185 &handle,
8186 &crate::member_comms_id::mob_member_id(runtime_member_id),
8187 ))
8188 .await
8189 {
8190 Ok(topology_restore_warning) => {
8191 if let Some(store) = console_events {
8192 store
8193 .record_lifecycle(
8194 &identity,
8195 "identity_reset",
8196 json!({
8197 "scope": "reset_all",
8198 "topology_restore_warning": topology_restore_warning.clone(),
8199 }),
8200 )
8201 .await;
8202 }
8203 reset_main.push(identity.clone());
8204 let mut detail = json!({ "identity": identity });
8205 if let Some(warning) = topology_restore_warning {
8206 detail["topology_restore_warning"] = warning;
8207 }
8208 reset_details.push(detail);
8209 }
8210 Err(err) => failures.push(json!({
8211 "identity": identity,
8212 "error": err,
8213 })),
8214 }
8215 } else {
8216 if let Some(identity_runtime) = identity_runtime
8217 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8218 && let Ok(registered_status) = identity_runtime.status(&parsed_identity).await
8219 {
8220 let registered_runtime_id = registered_status
8221 .agent_runtime_id
8222 .as_ref()
8223 .map(crate::identity_first::AgentRuntimeId::as_str);
8224 let registered_visible = registered_runtime_id
8225 .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
8226 if duplicate_live_identities.contains(&identity) && !registered_visible {
8227 failures.push(json!({
8228 "identity": identity,
8229 "error": "ambiguous live identity alias",
8230 }));
8231 continue;
8232 }
8233 if let Some(registered_runtime_id) = registered_runtime_id
8234 && let Some((live_identity, _live_session_id)) =
8235 raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
8236 && live_identity != &identity
8237 {
8238 failures.push(json!({
8239 "identity": identity,
8240 "error": format!(
8241 "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
8242 ),
8243 "kind": "stale_live_identity_alias",
8244 }));
8245 continue;
8246 }
8247 if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(&identity) {
8248 if !registered_runtime_id
8249 .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
8250 {
8251 failures.push(json!({
8252 "identity": identity,
8253 "error": format!(
8254 "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
8255 registered_runtime_id.unwrap_or("<none>"),
8256 live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
8257 ),
8258 "kind": "stale_live_identity_alias",
8259 }));
8260 continue;
8261 }
8262 if let Some(registered_runtime_id) = registered_runtime_id
8263 && let Some(registered_session_id) = registered_status
8264 .session_id
8265 .as_ref()
8266 .map(ToString::to_string)
8267 && let Some(Some(live_session_id)) =
8268 raw_session_id_by_identity_runtime_member
8269 .get(&(identity.clone(), registered_runtime_id.to_string()))
8270 && live_session_id != ®istered_session_id
8271 {
8272 failures.push(json!({
8273 "identity": identity,
8274 "error": format!(
8275 "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}"
8276 ),
8277 "kind": "stale_live_identity_alias",
8278 }));
8279 continue;
8280 }
8281 }
8282 match identity_runtime.retire(&parsed_identity).await {
8283 Ok(token) => {
8284 let keep_runtime_member_id = registered_status
8285 .agent_runtime_id
8286 .as_ref()
8287 .filter(|_| identity_runtime.has_session_bridge())
8288 .map(crate::identity_first::AgentRuntimeId::as_str);
8289 match retire_stale_console_members_for_identity(
8290 &handle,
8291 visibility_policy,
8292 parsed_identity.as_str(),
8293 keep_runtime_member_id,
8294 )
8295 .await
8296 {
8297 Ok(()) => {
8298 retired_delegate_details.push(json!({ "identity": identity }));
8299 retired_delegates.push(json!({ "identity": identity }));
8300 if let Some(store) = console_events {
8301 store
8302 .record_lifecycle(
8303 parsed_identity.as_str(),
8304 "identity_retired",
8305 json!({
8306 "scope": "reset_all",
8307 "dynamic": true,
8308 "fencing_token": token.get(),
8309 }),
8310 )
8311 .await;
8312 }
8313 }
8314 Err(err) => {
8315 warnings.push(json!({
8316 "identity": identity,
8317 "kind": "stale_member_cleanup_failed_after_identity_retire",
8318 "message": err,
8319 }));
8320 retired_delegate_details.push(json!({
8321 "identity": identity,
8322 "cleanup_warning": warnings.last().cloned(),
8323 }));
8324 retired_delegates.push(json!({ "identity": identity }));
8325 if let Some(store) = console_events {
8326 store
8327 .record_lifecycle(
8328 parsed_identity.as_str(),
8329 "identity_retired",
8330 json!({
8331 "scope": "reset_all",
8332 "dynamic": true,
8333 "fencing_token": token.get(),
8334 "cleanup_warning": warnings.last().cloned(),
8335 }),
8336 )
8337 .await;
8338 }
8339 }
8340 }
8341 }
8342 Err(err) => failures.push(json!({
8343 "identity": identity,
8344 "error": err.to_string(),
8345 })),
8346 }
8347 continue;
8348 }
8349 let runtime_member_id = runtime_member_id_by_identity
8350 .get(&identity)
8351 .map(String::as_str)
8352 .unwrap_or(identity.as_str());
8353 if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
8354 && bound_identity != &identity
8355 {
8356 failures.push(json!({
8357 "identity": identity,
8358 "error": format!(
8359 "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
8360 ),
8361 "kind": "stale_live_identity_alias",
8362 }));
8363 continue;
8364 }
8365 match retire_console_member(
8366 &handle,
8367 &crate::member_comms_id::mob_member_id(runtime_member_id),
8368 )
8369 .await
8370 {
8371 Ok(()) => {
8372 if let Some(store) = console_events {
8373 store
8374 .record_lifecycle(
8375 &identity,
8376 "identity_retired",
8377 json!({ "scope": "reset_all", "dynamic": true }),
8378 )
8379 .await;
8380 }
8381 retired_delegates.push(json!({ "identity": identity }));
8382 retired_delegate_details.push(json!({ "identity": identity }));
8383 }
8384 Err(err) => failures.push(json!({
8385 "identity": identity,
8386 "error": err,
8387 })),
8388 }
8389 }
8390 }
8391
8392 let startup_history = if failures.is_empty() {
8393 if let Some(aggregator) = console_aggregator {
8394 Box::pin(wait_for_reset_startup_history(
8395 aggregator,
8396 reset_main.iter().cloned().collect::<BTreeSet<_>>(),
8397 Duration::from_secs(10),
8398 ))
8399 .await
8400 .unwrap_or_else(|err| json!({ "error": err.to_string() }))
8401 } else {
8402 Value::Null
8403 }
8404 } else {
8405 Value::Null
8406 };
8407
8408 Ok(json!({
8409 "reset": reset_main,
8410 "retired_delegates": retired_delegates,
8411 "reset_details": reset_details,
8412 "retired_delegate_details": retired_delegate_details,
8413 "warnings": warnings,
8414 "failed": failures,
8415 "startup_history": startup_history,
8416 }))
8417}
8418
8419async fn wait_for_reset_startup_history(
8420 aggregator: &MobKitConsoleAggregator,
8421 identities: BTreeSet<String>,
8422 timeout: Duration,
8423) -> ConsoleLogResult<Value> {
8424 if identities.is_empty() {
8425 return Ok(json!({
8426 "timeout": false,
8427 "ready": Vec::<String>::new(),
8428 "pending": Vec::<String>::new(),
8429 }));
8430 }
8431
8432 let deadline = Instant::now() + timeout;
8433 let mut pending = identities;
8434 let mut ready = BTreeSet::new();
8435 while !pending.is_empty() {
8436 for identity in pending.clone() {
8437 let page = Box::pin(aggregator.query_timeline(ConsoleTimelineQuery {
8438 identity: Some(identity.clone()),
8439 limit: 1000,
8440 ..ConsoleTimelineQuery::default()
8441 }))
8442 .await?;
8443 let startup_completed = page.frames.iter().any(|frame| {
8444 matches!(
8445 frame.kind.as_str(),
8446 "interaction_complete" | "turn_completed"
8447 )
8448 });
8449 if startup_completed {
8450 pending.remove(&identity);
8451 ready.insert(identity);
8452 }
8453 }
8454
8455 if pending.is_empty() {
8456 break;
8457 }
8458 if Instant::now() >= deadline {
8459 return Ok(json!({
8460 "timeout": true,
8461 "ready": ready.into_iter().collect::<Vec<_>>(),
8462 "pending": pending.into_iter().collect::<Vec<_>>(),
8463 }));
8464 }
8465 tokio::time::sleep(Duration::from_millis(250)).await;
8466 }
8467
8468 Ok(json!({
8469 "timeout": false,
8470 "ready": ready.into_iter().collect::<Vec<_>>(),
8471 "pending": Vec::<String>::new(),
8472 }))
8473}
8474
8475fn dedupe_console_members_by_identity(members: &mut Vec<ConsoleMember>) {
8476 let mut seen_member_ids = BTreeSet::new();
8477 members.retain(|member| seen_member_ids.insert(member.agent_identity.clone()));
8478}
8479
8480fn console_member_console_identity(member: &ConsoleMember) -> &str {
8481 member
8482 .labels
8483 .get("agent_identity")
8484 .filter(|value| !value.trim().is_empty())
8485 .map_or(member.agent_identity.as_str(), String::as_str)
8486}
8487
8488fn console_identity_record_from_console_member(member: &ConsoleMember) -> ConsoleIdentityRecord {
8489 let identity = console_member_console_identity(member).to_string();
8490 let addressable = member
8491 .labels
8492 .get("addressable")
8493 .map(|value| !value.eq_ignore_ascii_case("false"))
8494 .unwrap_or(true)
8495 && member.state == MEMBER_STATE_ACTIVE;
8496 let visibility = if member.state == MEMBER_STATE_RETIRING {
8497 ConsoleVisibility::RetiredReadable
8498 } else if addressable {
8499 ConsoleVisibility::Addressable
8500 } else {
8501 ConsoleVisibility::Hidden
8502 };
8503 ConsoleIdentityRecord {
8504 identity: identity.clone(),
8505 display_name: member
8506 .labels
8507 .get("display_name")
8508 .cloned()
8509 .unwrap_or(identity),
8510 runtime_key: "runtime".to_string(),
8511 runtime_member_id: member.agent_identity.clone(),
8512 session_id: member.session_id.clone(),
8513 visibility,
8514 addressable,
8515 health: member.state.clone(),
8516 topology_peers: member.wired_to.clone(),
8517 labels: member.labels.clone(),
8518 }
8519}
8520
8521fn baseline_spec_visible_to_console(
8522 visibility_policy: &dyn ConsoleVisibilityPolicy,
8523 spec: &SpawnMemberSpec,
8524) -> bool {
8525 let mut labels = spec.labels.clone().unwrap_or_default();
8526 labels
8527 .entry("role".to_string())
8528 .or_insert_with(|| spec.role_name.to_string());
8529 let record = ConsoleIdentityRecord {
8530 identity: spec.identity.to_string(),
8531 display_name: spec.identity.to_string(),
8532 runtime_key: "baseline".to_string(),
8533 runtime_member_id: spec.identity.to_string(),
8534 session_id: None,
8535 visibility: ConsoleVisibility::Addressable,
8536 addressable: true,
8537 health: "baseline".to_string(),
8538 topology_peers: Vec::new(),
8539 labels,
8540 };
8541 let member = ConsoleMember {
8542 agent_identity: spec.identity.to_string(),
8543 role: spec.role_name.to_string(),
8544 state: MEMBER_STATE_ACTIVE.to_string(),
8545 model_capabilities: ConsoleModelCapabilities::default(),
8546 runtime_mode: spec
8547 .runtime_mode
8548 .as_ref()
8549 .map(std::string::ToString::to_string),
8550 session_id: None,
8551 wired_to: Vec::new(),
8552 labels: record.labels.clone(),
8553 };
8554 visibility_policy.member_visible(&member) && visibility_policy.identity_visible(&record)
8555}
8556
8557pub(crate) async fn prime_access_cache_from_runtime(
8571 runtime: &MobRuntime,
8572 access: &AccessController,
8573) {
8574 if !access.enabled() {
8575 return;
8576 }
8577 let registered = runtime.console_identity_labels().await;
8578 prime_access_cache_from_handle_with_registry(&runtime.handle(), access, ®istered).await;
8579}
8580
8581pub(crate) async fn prime_access_cache_from_handle(handle: &MobHandle, access: &AccessController) {
8582 if !access.enabled() {
8583 return;
8584 }
8585 prime_access_cache_from_handle_with_registry(handle, access, &BTreeMap::new()).await;
8586}
8587
8588async fn prime_access_cache_from_handle_with_registry(
8589 handle: &MobHandle,
8590 access: &AccessController,
8591 registered: &BTreeMap<String, BTreeMap<String, String>>,
8592) {
8593 let mut registry_only: BTreeMap<&String, &BTreeMap<String, String>> =
8594 registered.iter().collect();
8595 for entry in handle.list_all_members().await {
8596 let member_identity =
8599 crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
8600 let console_identity = entry
8603 .labels
8604 .get("agent_identity")
8605 .map(|value| value.trim())
8606 .filter(|value| !value.is_empty())
8607 .map_or_else(|| member_identity.clone(), ToString::to_string);
8608 let mut labels = entry.labels.clone();
8609 crate::console_spawn::sanitize_unverified_lineage_labels(&mut labels);
8613 if let Some(spawn_labels) = registry_only.remove(&console_identity) {
8614 crate::console_spawn::merge_registered_labels(&mut labels, spawn_labels);
8615 }
8616 access.record_agent_attributes(AgentResourceAttributes {
8617 identity: console_identity,
8618 agent_id: Some(member_identity),
8619 role: Some(entry.role.to_string()),
8620 labels,
8621 });
8622 }
8623 for (identity, spawn_labels) in registry_only {
8624 access.record_agent_attributes(AgentResourceAttributes {
8625 identity: identity.clone(),
8626 agent_id: None,
8627 role: spawn_labels.get("role").cloned(),
8628 labels: spawn_labels.clone(),
8629 });
8630 }
8631}
8632
8633async fn project_console_members_from_handle(
8634 handle: &MobHandle,
8635 host_identity: Option<&str>,
8636 source_mob_id: Option<&str>,
8637 read_model: &ConsoleSnapshotReadModelState,
8638 registered_labels: &BTreeMap<String, BTreeMap<String, String>>,
8639) -> (Vec<ConsoleMember>, BTreeMap<String, String>) {
8640 let entries = handle.list_members_including_retiring().await;
8644 let mut members = Vec::with_capacity(entries.len());
8645 let mut session_owner_by_id = BTreeMap::new();
8646 for entry in &entries {
8647 let identity =
8650 crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
8651 let session_id = read_model.session_id_by_identity.get(&identity).cloned();
8652 if let Some(session_id) = session_id.as_ref() {
8653 session_owner_by_id.insert(session_id.clone(), identity.clone());
8654 }
8655 let model_capabilities =
8656 model_capabilities_for_role(handle.definition(), entry.role.as_str());
8657 let mut labels = entry.labels.clone();
8658 crate::console_spawn::sanitize_unverified_lineage_labels(&mut labels);
8663 let console_identity_key = entry
8664 .labels
8665 .get("agent_identity")
8666 .map(|value| value.trim())
8667 .filter(|value| !value.is_empty())
8668 .map_or(identity.as_str(), |value| value);
8669 if let Some(spawn_labels) = registered_labels.get(console_identity_key) {
8670 crate::console_spawn::merge_registered_labels(&mut labels, spawn_labels);
8671 }
8672 if let Some(host_identity) = host_identity {
8673 labels
8674 .entry("delegate_host_identity".to_string())
8675 .or_insert_with(|| host_identity.to_string());
8676 labels
8677 .entry("group".to_string())
8678 .or_insert_with(|| "Coordinators".to_string());
8679 }
8680 if let Some(source_mob_id) = source_mob_id {
8681 labels
8682 .entry("source_mob_id".to_string())
8683 .or_insert_with(|| source_mob_id.to_string());
8684 }
8685 let mut wired_to: Vec<String> = entry
8686 .wired_to
8687 .iter()
8688 .map(|peer| crate::member_comms_id::runtime_alias_str(peer.as_str()).into_owned())
8689 .collect();
8690 if let Some(host_identity) = host_identity
8691 && !wired_to.iter().any(|peer| peer == host_identity)
8692 {
8693 wired_to.push(host_identity.to_string());
8694 }
8695 members.push(ConsoleMember {
8696 agent_identity: identity,
8697 role: entry.role.to_string(),
8698 state: member_status_state_string(entry.status),
8699 model_capabilities,
8700 runtime_mode: Some(entry.runtime_mode.to_string()),
8701 session_id,
8702 wired_to,
8703 labels,
8704 });
8705 }
8706 (members, session_owner_by_id)
8707}
8708
8709async fn build_aggregator_live_snapshot(
8710 aggregator: &MobKitConsoleAggregator,
8711 config_module_ids: &[String],
8712) -> Result<ConsoleLiveSnapshot, Box<dyn std::error::Error + Send + Sync>> {
8713 let identities = Box::pin(aggregator.list_identities()).await?;
8714 let mut members = Vec::with_capacity(identities.len());
8715 for identity in &identities {
8716 let mut labels = identity.labels.clone();
8717 labels
8718 .entry("display_name".to_string())
8719 .or_insert_with(|| identity.display_name.clone());
8720 labels
8721 .entry("addressable".to_string())
8722 .or_insert_with(|| identity.addressable.to_string());
8723 let wired_to = identity.topology_peers.clone();
8727 members.push(ConsoleMember {
8728 agent_identity: identity.identity.clone(),
8729 role: labels
8730 .get("role")
8731 .cloned()
8732 .unwrap_or_else(|| "identity".to_string()),
8733 state: identity.health.clone(),
8734 model_capabilities: ConsoleModelCapabilities::default(),
8735 runtime_mode: Some("console_aggregator".to_string()),
8736 session_id: identity.session_id.clone(),
8737 wired_to,
8738 labels,
8739 });
8740 }
8741 members.sort_by(|left, right| left.agent_identity.cmp(&right.agent_identity));
8742 let agents = members
8743 .iter()
8744 .map(|member| ConsoleAgentLiveSnapshot {
8745 agent_id: member.agent_identity.clone(),
8746 member_id: member.agent_identity.clone(),
8747 label: member
8748 .labels
8749 .get("display_name")
8750 .cloned()
8751 .unwrap_or_else(|| member.agent_identity.clone()),
8752 kind: "meerkat".to_string(),
8753 identity: Some(member.agent_identity.clone()),
8754 role: Some(member.role.clone()),
8755 state: Some(member.state.clone()),
8756 session_id: member.session_id.clone(),
8757 model_capabilities: member.model_capabilities.clone(),
8758 response_phase: None,
8759 watched: None,
8760 alert_level: None,
8761 degraded: None,
8762 degraded_reason: None,
8763 })
8764 .collect::<Vec<_>>();
8765 let loaded_modules = if config_module_ids.is_empty() {
8766 members
8767 .iter()
8768 .map(|member| member.agent_identity.clone())
8769 .collect()
8770 } else {
8771 config_module_ids.to_vec()
8772 };
8773 Ok(ConsoleLiveSnapshot::new(
8774 Some("console-aggregator".to_string()),
8775 true,
8776 loaded_modules,
8777 agents,
8778 members,
8779 true,
8780 ))
8781}
8782
8783pub async fn console_frontend_index_handler() -> impl IntoResponse {
8784 (
8785 [
8786 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
8787 (header::CACHE_CONTROL, "no-store"),
8788 ],
8789 CONSOLE_FRONTEND_INDEX_HTML,
8790 )
8791}
8792
8793pub async fn console_frontend_app_js_handler() -> impl IntoResponse {
8794 (
8795 [
8796 (
8797 header::CONTENT_TYPE,
8798 "application/javascript; charset=utf-8",
8799 ),
8800 (header::CACHE_CONTROL, "no-store"),
8801 ],
8802 CONSOLE_FRONTEND_APP_JS,
8803 )
8804}
8805
8806pub async fn console_frontend_app_css_handler() -> impl IntoResponse {
8807 (
8808 [
8809 (header::CONTENT_TYPE, "text/css; charset=utf-8"),
8810 (header::CACHE_CONTROL, "no-store"),
8811 ],
8812 CONSOLE_FRONTEND_APP_CSS,
8813 )
8814}
8815
8816#[cfg(test)]
8817#[allow(clippy::expect_used, clippy::large_futures)]
8818mod tests {
8819 use super::ConsoleTimelineHttpQuery;
8820 use super::{
8821 ConsoleSnapshotReadModel, ConsoleSnapshotReadModelState, MAX_MULTIPART_BODY_BYTES,
8822 MAX_MULTIPART_IMAGE_BYTES, MultipartImageUpload, apply_console_visibility_policy,
8823 build_aggregator_live_snapshot, collect_console_snapshot_read_model,
8824 console_send_identity_first, console_send_with_identity_first_fallback,
8825 console_timeline_replay_unavailable_response, cursor_is_after,
8826 dedupe_console_members_by_identity, externalize_image_upload_placeholders,
8827 externalize_single_image_upload, handle_console_aggregator_rpc, handle_console_runtime_rpc,
8828 handle_console_runtime_rpc_with_visibility, member_id_matches_durable_identity,
8829 project_console_members_from_handle, query_timeline_snapshot, timeline_query_from_http,
8830 };
8831 use crate::access::{
8832 ACTION_AGENT_MEMORY_DELETE, ACTION_AGENT_MEMORY_READ, ACTION_AGENT_MEMORY_WRITE,
8833 ACTION_AGENT_SEND, ACTION_AGENT_VIEW, AccessController,
8834 };
8835 use crate::blob_store::{BinaryBlobStore, ObjectStoreBlobStore};
8836 use crate::console_aggregator::{
8837 AllowAllConsoleVisibilityPolicy, ConsoleIdentityRecord,
8838 HideImplicitDelegateMembersConsoleVisibilityPolicy,
8839 };
8840 use crate::console_aggregator::{
8841 ConsoleCursor, ConsoleFrameSource, ConsoleFrameSourceKind, ConsoleFrameStatus,
8842 ConsoleTimelineQuery, ConsoleTimelineWindowQuery, ConsoleVisibilityPolicy,
8843 MobKitConsoleAggregator, NewConsoleFrame,
8844 };
8845 use crate::identity_first::contracts::{ContinuityStore, LeaseProvider};
8846 use crate::identity_first::{
8847 AgentAddressability, AgentBuildDraft, AgentIdentity, AgentMemoryConfig,
8848 AgentMemoryRuntimeInjector, AgentRuntimeId, BridgeError, CheckpointVersion,
8849 ContinuityGeneration, ContinuityRecord, DurabilityPolicy, DurableAgentSpec, FencingToken,
8850 IdentityLifecycleState, IdentityRuntime, IdentityRuntimeConfig, LeaseAcquireResult,
8851 LeaseGrant, LocalContinuityStore, LocalLeaseProvider, ManagedPeerEdge,
8852 MarkdownAgentMemoryStore, ResumeSessionOutcome, SessionBridge, SessionSnapshot,
8853 };
8854 use crate::mob_handle_runtime::{MobRuntime, model_capabilities_for_role};
8855 use crate::rpc::{JSONRPC_VERSION, JsonRpcRequest};
8856 use crate::runtime::{ConsoleAgentLiveSnapshot, ConsoleLiveSnapshot, ConsoleMember};
8857 use crate::unified_runtime::ConsoleEventStore;
8858 use crate::{MobBootstrapOptions, MobBootstrapSpec};
8859 use bytes::Bytes;
8860 use meerkat::{AgentFactory, Config, build_ephemeral_service};
8861 use meerkat_client::TestClient;
8862 use meerkat_core::types::HandlingMode;
8863 use meerkat_mob::ProfileName;
8864 use meerkat_mob::{MobDefinition, MobStorage, SpawnMemberSpec};
8865 use serde_json::{Value, json};
8866 use std::collections::BTreeMap;
8867 use std::sync::atomic::{AtomicUsize, Ordering};
8868 use std::sync::{Arc, Mutex};
8869 use std::time::Duration;
8870
8871 struct BlockingIdentityBridge {
8872 deliver_calls: Arc<AtomicUsize>,
8873 }
8874
8875 struct RecordingIdentityBridge {
8876 session_id: meerkat_core::types::SessionId,
8877 handling_modes: Arc<Mutex<Vec<HandlingMode>>>,
8878 }
8879
8880 #[async_trait::async_trait]
8881 impl SessionBridge for BlockingIdentityBridge {
8882 async fn create_session(
8883 &self,
8884 _identity: &AgentIdentity,
8885 _runtime_id: &AgentRuntimeId,
8886 _spec: &DurableAgentSpec,
8887 _draft: &AgentBuildDraft,
8888 session_id: &meerkat_core::types::SessionId,
8889 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
8890 Ok(session_id.clone())
8891 }
8892
8893 async fn resume_session(
8894 &self,
8895 _identity: &AgentIdentity,
8896 _runtime_id: &AgentRuntimeId,
8897 _spec: &DurableAgentSpec,
8898 _draft: &AgentBuildDraft,
8899 session_id: &meerkat_core::types::SessionId,
8900 _snapshot: &SessionSnapshot,
8901 ) -> Result<ResumeSessionOutcome, BridgeError> {
8902 Ok(ResumeSessionOutcome::Resumed {
8903 session_id: session_id.clone(),
8904 })
8905 }
8906
8907 async fn deliver(
8908 &self,
8909 _runtime_id: &AgentRuntimeId,
8910 _content: &meerkat_core::ContentInput,
8911 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
8912 self.deliver_calls.fetch_add(1, Ordering::SeqCst);
8913 std::future::pending().await
8914 }
8915
8916 async fn checkpoint_session(
8917 &self,
8918 _runtime_id: &AgentRuntimeId,
8919 _session_id: &meerkat_core::types::SessionId,
8920 ) -> Result<SessionSnapshot, BridgeError> {
8921 Err(BridgeError::Mob("checkpoint not used in test".to_string()))
8922 }
8923
8924 async fn retire_member(&self, _runtime_id: &AgentRuntimeId) -> Result<(), BridgeError> {
8925 Ok(())
8926 }
8927 }
8928
8929 #[async_trait::async_trait]
8930 impl SessionBridge for RecordingIdentityBridge {
8931 async fn create_session(
8932 &self,
8933 _identity: &AgentIdentity,
8934 _runtime_id: &AgentRuntimeId,
8935 _spec: &DurableAgentSpec,
8936 _draft: &AgentBuildDraft,
8937 session_id: &meerkat_core::types::SessionId,
8938 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
8939 Ok(session_id.clone())
8940 }
8941
8942 async fn resume_session(
8943 &self,
8944 _identity: &AgentIdentity,
8945 _runtime_id: &AgentRuntimeId,
8946 _spec: &DurableAgentSpec,
8947 _draft: &AgentBuildDraft,
8948 session_id: &meerkat_core::types::SessionId,
8949 _snapshot: &SessionSnapshot,
8950 ) -> Result<ResumeSessionOutcome, BridgeError> {
8951 Ok(ResumeSessionOutcome::Resumed {
8952 session_id: session_id.clone(),
8953 })
8954 }
8955
8956 async fn deliver(
8957 &self,
8958 runtime_id: &AgentRuntimeId,
8959 content: &meerkat_core::ContentInput,
8960 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
8961 self.deliver_with_mode(runtime_id, content, HandlingMode::Queue)
8962 .await
8963 }
8964
8965 async fn deliver_with_mode(
8966 &self,
8967 _runtime_id: &AgentRuntimeId,
8968 _content: &meerkat_core::ContentInput,
8969 handling_mode: HandlingMode,
8970 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
8971 self.handling_modes
8972 .lock()
8973 .map_err(|_| BridgeError::Mob("handling modes mutex poisoned".to_string()))?
8974 .push(handling_mode);
8975 Ok(self.session_id.clone())
8976 }
8977
8978 async fn checkpoint_session(
8979 &self,
8980 _runtime_id: &AgentRuntimeId,
8981 _session_id: &meerkat_core::types::SessionId,
8982 ) -> Result<SessionSnapshot, BridgeError> {
8983 Err(BridgeError::Mob("checkpoint not used in test".to_string()))
8984 }
8985
8986 async fn retire_member(&self, _runtime_id: &AgentRuntimeId) -> Result<(), BridgeError> {
8987 Ok(())
8988 }
8989 }
8990
8991 async fn build_empty_console_test_runtime(
8992 mob_id: &str,
8993 ) -> Result<(tempfile::TempDir, MobRuntime), Box<dyn std::error::Error + Send + Sync>> {
8994 let temp_dir = tempfile::tempdir()?;
8995 let session_path = temp_dir.path().join("sessions");
8996 std::fs::create_dir_all(&session_path)?;
8997 let factory = AgentFactory::new(&session_path).comms(true);
8998 let session_service = Arc::new(build_ephemeral_service(factory, Config::default(), 16));
8999 let definition = MobDefinition::from_toml(&format!(
9000 r#"
9001[mob]
9002id = "{mob_id}"
9003
9004[profiles.worker]
9005model = "gpt-5.5"
9006external_addressable = true
9007
9008[profiles.worker.tools]
9009comms = true
9010"#
9011 ))?;
9012 let runtime = MobRuntime::bootstrap(
9013 MobBootstrapSpec::new(definition, MobStorage::in_memory(), session_service)
9014 .with_options(MobBootstrapOptions {
9015 allow_ephemeral_sessions: true,
9016 notify_orchestrator_on_resume: true,
9017 default_llm_client: Some(Arc::new(TestClient::default())),
9018 }),
9019 )
9020 .await?;
9021 Ok((temp_dir, runtime))
9022 }
9023
9024 fn rpc_request(method: &str) -> JsonRpcRequest {
9025 JsonRpcRequest {
9026 jsonrpc: JSONRPC_VERSION.to_string(),
9027 id: Some(json!(1)),
9028 method: method.to_string(),
9029 params: json!({}),
9030 }
9031 }
9032
9033 fn rpc_request_with_params(method: &str, params: Value) -> JsonRpcRequest {
9034 JsonRpcRequest {
9035 jsonrpc: JSONRPC_VERSION.to_string(),
9036 id: Some(json!(1)),
9037 method: method.to_string(),
9038 params,
9039 }
9040 }
9041
9042 #[tokio::test]
9043 async fn read_only_aggregator_capabilities_omit_mutating_methods() {
9044 let response = handle_console_aggregator_rpc(
9045 None,
9046 rpc_request("mobkit/capabilities"),
9047 true,
9048 true,
9049 None,
9050 None,
9051 )
9052 .await;
9053
9054 let methods = response["result"]["methods"]
9055 .as_array()
9056 .expect("capabilities methods");
9057 assert!(
9058 methods.iter().all(|method| method != "mobkit/console/send"),
9059 "read-only capabilities must omit send: {methods:#?}"
9060 );
9061 assert_eq!(response["result"]["read_only"], json!(true));
9062 assert_eq!(
9063 response["result"]["runtime_capabilities"]["can_send_messages"],
9064 json!(false)
9065 );
9066 }
9067
9068 #[tokio::test]
9069 async fn read_only_aggregator_denies_direct_mutating_rpc() {
9070 let response = handle_console_aggregator_rpc(
9071 None,
9072 rpc_request_with_params(
9073 "mobkit/console/send",
9074 json!({
9075 "identity": "worker",
9076 "content": "hello",
9077 "origin": "test",
9078 "idempotency_key": "read-only-send",
9079 }),
9080 ),
9081 true,
9082 true,
9083 None,
9084 None,
9085 )
9086 .await;
9087
9088 assert_eq!(response["result"], Value::Null);
9089 assert_eq!(response["error"]["code"], json!(-32010));
9090 assert_eq!(response["error"]["data"]["kind"], json!("read_only"));
9091 }
9092
9093 #[test]
9094 fn read_only_mutating_methods_include_state_draining_collect_completed() {
9095 assert!(super::is_console_mutating_rpc_method(
9096 "mobkit/collect_completed"
9097 ));
9098 }
9099
9100 #[test]
9101 fn authenticated_gating_approver_comes_from_console_principal() {
9102 let forged_params = json!({ "approver_id": "forged-browser-value" });
9103
9104 assert_eq!(
9105 super::resolve_gating_approver_id(&forged_params, Some("admin@example.com"),),
9106 Ok("admin@example.com".to_string()),
9107 );
9108 }
9109
9110 #[test]
9111 fn unauthenticated_local_gating_approver_requires_request_param() {
9112 assert_eq!(
9113 super::resolve_gating_approver_id(&json!({ "approver_id": " local-operator " }), None,),
9114 Ok("local-operator".to_string()),
9115 );
9116 assert_eq!(
9117 super::resolve_gating_approver_id(&json!({}), None),
9118 Err("approver_id required"),
9119 );
9120 }
9121
9122 #[test]
9123 fn internal_error_does_not_disclose_backend_details() {
9124 let response = super::internal_error(json!(7), "secret backend DSN");
9125
9126 assert_eq!(response["error"]["code"], json!(-32000));
9127 assert_eq!(response["error"]["message"], json!("internal error"));
9128 assert_eq!(response["error"]["data"]["error"], json!("internal_error"));
9129 assert!(!response.to_string().contains("secret backend DSN"));
9130 }
9131
9132 #[test]
9133 fn console_send_public_message_hides_dispatch_details() {
9134 let message = super::console_send_public_message(
9135 &crate::console_aggregator::ConsoleSendError::Dispatch(
9136 "secret backend DSN".to_string(),
9137 ),
9138 );
9139
9140 assert_eq!(message, "console send failed");
9141 assert!(!message.contains("secret backend DSN"));
9142 }
9143
9144 #[test]
9145 fn console_send_json_rpc_error_hides_backend_details() {
9146 let response = super::console_send_rpc_error(
9147 json!(7),
9148 crate::console_aggregator::ConsoleSendError::Dispatch("secret backend DSN".to_string()),
9149 );
9150
9151 assert_eq!(response["error"]["code"], json!(-32000));
9152 assert_eq!(response["error"]["message"], json!("console send failed"));
9153 assert!(!response.to_string().contains("secret backend DSN"));
9154 }
9155
9156 #[test]
9157 fn console_timeline_replay_error_hides_backend_details() {
9158 let response = super::console_timeline_replay_unavailable_response(
9159 json!(7),
9160 Box::new(std::io::Error::other("secret backend DSN")),
9161 None,
9162 None,
9163 );
9164
9165 assert_eq!(
9166 response["error"]["code"],
9167 json!(crate::rpc::CONSOLE_TIMELINE_REPLAY_UNAVAILABLE_CODE)
9168 );
9169 assert_eq!(
9170 response["error"]["message"],
9171 json!("timeline replay unavailable")
9172 );
9173 assert!(!response.to_string().contains("secret backend DSN"));
9174 }
9175
9176 #[test]
9177 fn gating_decision_error_hides_backend_details() {
9178 let response = super::gating_decision_failed_error(json!(7), "secret backend DSN");
9179
9180 assert_eq!(response["error"]["code"], json!(-32602));
9181 assert_eq!(
9182 response["error"]["message"],
9183 json!("gating decision failed")
9184 );
9185 assert!(!response.to_string().contains("secret backend DSN"));
9186 }
9187
9188 #[tokio::test]
9189 async fn console_runtime_identity_controls_resolve_durable_member_aliases()
9190 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9191 let (_temp_dir, runtime) =
9192 build_empty_console_test_runtime("console-identity-control-alias").await?;
9193 let mut labels = BTreeMap::new();
9194 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
9195 labels.insert("display_name".to_string(), "Review Agent".to_string());
9196 runtime
9197 .handle()
9198 .spawn_spec(
9199 SpawnMemberSpec::from_wire(
9200 "worker".to_string(),
9201 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
9204 Some("You are Review Agent.".into()),
9205 None,
9206 None,
9207 )
9208 .with_labels(labels),
9209 )
9210 .await?;
9211
9212 let durable_status = Box::pin(handle_console_runtime_rpc(
9213 &runtime,
9214 None,
9215 None,
9216 None,
9217 None,
9218 None,
9219 None,
9220 None,
9221 None,
9222 rpc_request_with_params(
9223 "mobkit/status_identity",
9224 json!({ "identity": "review:singleton" }),
9225 ),
9226 true,
9227 ))
9228 .await;
9229 assert_eq!(durable_status["error"], Value::Null);
9230 assert_eq!(
9231 durable_status["result"]["identity"],
9232 json!("review:singleton")
9233 );
9234 assert_eq!(
9235 durable_status["result"]["agent_runtime_id"],
9236 json!("rt:review:singleton:0")
9237 );
9238
9239 let runtime_id_status = Box::pin(handle_console_runtime_rpc(
9240 &runtime,
9241 None,
9242 None,
9243 None,
9244 None,
9245 None,
9246 None,
9247 None,
9248 None,
9249 rpc_request_with_params(
9250 "mobkit/status_identity",
9251 json!({ "identity": "rt:review:singleton:0" }),
9252 ),
9253 true,
9254 ))
9255 .await;
9256 assert_eq!(runtime_id_status["error"], Value::Null);
9257 assert_eq!(
9258 runtime_id_status["result"]["identity"],
9259 json!("review:singleton")
9260 );
9261
9262 let runtime_id_inspect = Box::pin(handle_console_runtime_rpc(
9263 &runtime,
9264 None,
9265 None,
9266 None,
9267 None,
9268 None,
9269 None,
9270 None,
9271 None,
9272 rpc_request_with_params(
9273 "mobkit/inspect_identity",
9274 json!({ "identity": "rt:review:singleton:0" }),
9275 ),
9276 true,
9277 ))
9278 .await;
9279 assert_eq!(runtime_id_inspect["error"], Value::Null);
9280 assert_eq!(
9281 runtime_id_inspect["result"]["identity"],
9282 json!("review:singleton")
9283 );
9284
9285 let respawn = Box::pin(handle_console_runtime_rpc(
9286 &runtime,
9287 None,
9288 None,
9289 None,
9290 Some(ConsoleEventStore::new()),
9291 None,
9292 None,
9293 None,
9294 None,
9295 rpc_request_with_params("mobkit/respawn", json!({ "identity": "review:singleton" })),
9296 true,
9297 ))
9298 .await;
9299 assert_eq!(respawn["error"], Value::Null);
9300 assert_eq!(respawn["result"]["identity"], json!("review:singleton"));
9301 assert_eq!(
9302 respawn["result"]["agent_runtime_id"],
9303 json!("rt:review:singleton:0")
9304 );
9305
9306 let reset_without_identity_runtime = Box::pin(handle_console_runtime_rpc(
9310 &runtime,
9311 None,
9312 None,
9313 None,
9314 Some(ConsoleEventStore::new()),
9315 None,
9316 None,
9317 None,
9318 None,
9319 rpc_request_with_params("mobkit/reset", json!({ "identity": "review:singleton" })),
9320 true,
9321 ))
9322 .await;
9323 assert_eq!(
9324 reset_without_identity_runtime["error"],
9325 Value::Null,
9326 "{reset_without_identity_runtime:#?}"
9327 );
9328 assert_eq!(
9329 reset_without_identity_runtime["result"]["identity"],
9330 json!("review:singleton")
9331 );
9332 assert_eq!(
9333 reset_without_identity_runtime["result"]["agent_runtime_id"],
9334 json!("rt:review:singleton:0")
9335 );
9336
9337 let reset_unknown_identity = Box::pin(handle_console_runtime_rpc(
9338 &runtime,
9339 None,
9340 None,
9341 None,
9342 Some(ConsoleEventStore::new()),
9343 None,
9344 None,
9345 None,
9346 None,
9347 rpc_request_with_params("mobkit/reset", json!({ "identity": "missing:member" })),
9348 true,
9349 ))
9350 .await;
9351 assert_ne!(reset_unknown_identity["error"], Value::Null);
9352 assert!(
9353 reset_unknown_identity["error"]["message"]
9354 .as_str()
9355 .unwrap_or_default()
9356 .contains("identity not found")
9357 );
9358
9359 let _ = runtime.handle().stop().await;
9360 Ok(())
9361 }
9362
9363 #[tokio::test]
9364 async fn console_runtime_identity_controls_reject_ambiguous_live_label_aliases()
9365 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9366 let (_temp_dir, runtime) =
9367 build_empty_console_test_runtime("console-identity-ambiguous-live-alias").await?;
9368 for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
9369 let mut labels = BTreeMap::new();
9370 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
9371 runtime
9372 .handle()
9373 .spawn_spec(
9374 SpawnMemberSpec::from_wire(
9375 "worker".to_string(),
9376 crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
9379 Some("You are a duplicate Review Agent.".into()),
9380 None,
9381 None,
9382 )
9383 .with_labels(labels),
9384 )
9385 .await?;
9386 }
9387
9388 for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
9389 for method in [
9390 "mobkit/status_identity",
9391 "mobkit/inspect_identity",
9392 "mobkit/retire",
9393 "mobkit/respawn",
9394 "mobkit/reset",
9395 ] {
9396 let response = Box::pin(handle_console_runtime_rpc(
9397 &runtime,
9398 None,
9399 None,
9400 None,
9401 Some(ConsoleEventStore::new()),
9402 None,
9403 None,
9404 None,
9405 None,
9406 rpc_request_with_params(method, json!({ "identity": requested_identity })),
9407 true,
9408 ))
9409 .await;
9410 assert_ne!(
9411 response["error"],
9412 Value::Null,
9413 "{method} must reject ambiguous live alias for {requested_identity}"
9414 );
9415 assert_eq!(
9416 response["error"]["data"]["kind"],
9417 json!("ambiguous_live_identity_alias"),
9418 "unexpected response for {method}/{requested_identity}: {response:#?}"
9419 );
9420 }
9421 }
9422
9423 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
9424 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
9425 lease_provider: Arc::new(LocalLeaseProvider::new()),
9426 runtime_instance_id: "console-identity-ambiguous-live-alias".to_string(),
9427 has_runtime_store: true,
9428 durability_policy: DurabilityPolicy::SyncWriteThrough,
9429 bridge: None,
9430 default_timeout: None,
9431 }));
9432 for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
9433 for method in ["mobkit/reset", "mobkit/delete_identity"] {
9434 let response = Box::pin(handle_console_runtime_rpc(
9435 &runtime,
9436 None,
9437 None,
9438 None,
9439 Some(ConsoleEventStore::new()),
9440 None,
9441 Some(identity_runtime.clone()),
9442 None,
9443 None,
9444 rpc_request_with_params(method, json!({ "identity": requested_identity })),
9445 true,
9446 ))
9447 .await;
9448 assert_ne!(
9449 response["error"],
9450 Value::Null,
9451 "{method} must reject ambiguous live alias for {requested_identity}"
9452 );
9453 assert_eq!(
9454 response["error"]["data"]["kind"],
9455 json!("ambiguous_live_identity_alias"),
9456 "unexpected response for {method}/{requested_identity}: {response:#?}"
9457 );
9458 }
9459 }
9460
9461 let _ = runtime.handle().stop().await;
9462 Ok(())
9463 }
9464
9465 #[tokio::test]
9466 async fn console_runtime_durable_identity_prefers_registered_live_over_duplicate_labels()
9467 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9468 let (_temp_dir, runtime) =
9469 build_empty_console_test_runtime("console-durable-wins-duplicate-live-labels").await?;
9470 for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
9471 let mut labels = BTreeMap::new();
9472 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
9473 runtime
9474 .handle()
9475 .spawn_spec(
9476 SpawnMemberSpec::from_wire(
9477 "worker".to_string(),
9478 crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
9481 Some("You are a Review Agent candidate.".into()),
9482 None,
9483 None,
9484 )
9485 .with_labels(labels),
9486 )
9487 .await?;
9488 }
9489
9490 let store = Arc::new(LocalContinuityStore::in_memory()?);
9491 let lease_provider = Arc::new(LocalLeaseProvider::new());
9492 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
9493 continuity_store: store.clone(),
9494 lease_provider: lease_provider.clone(),
9495 runtime_instance_id: "test-runtime".to_string(),
9496 has_runtime_store: true,
9497 durability_policy: DurabilityPolicy::SyncWriteThrough,
9498 bridge: None,
9499 default_timeout: None,
9500 }));
9501 let identity = AgentIdentity::parse("review:singleton")?;
9502 let registered_session_id = runtime
9503 .handle()
9504 .resolve_bridge_session_id_observation(&crate::member_comms_id::mob_member_id(
9505 "rt:review:singleton:0",
9506 ))
9507 .await
9508 .unwrap_or_else(meerkat_core::types::SessionId::new);
9509 let record = ContinuityRecord {
9510 identity: identity.clone(),
9511 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
9512 session_id: registered_session_id,
9513 generation: ContinuityGeneration::new(0),
9514 checkpoint_version: CheckpointVersion::new(0),
9515 };
9516 let grants = lease_provider
9517 .acquire_leases(std::slice::from_ref(&identity), "test-runtime")
9518 .await?;
9519 let grant = match grants.get(&identity).cloned() {
9520 Some(LeaseAcquireResult::Acquired(grant)) => grant,
9521 other => return Err(format!("expected acquired lease, got {other:?}").into()),
9522 };
9523 store
9524 .upsert_continuity_record(&record, grant.fencing_token)
9525 .await?;
9526 identity_runtime
9527 .register(
9528 DurableAgentSpec {
9529 identity: identity.clone(),
9530 profile: ProfileName::from("worker"),
9531 addressability: AgentAddressability::Addressable,
9532 display_name: None,
9533 labels: BTreeMap::new(),
9534 context: None,
9535 additional_instructions: Vec::new(),
9536 initial_message: None,
9537 runtime_mode_override: None,
9538 backend: None,
9539 binding: None,
9540 },
9541 IdentityLifecycleState::Active,
9542 Some(record),
9543 Some(grant),
9544 )
9545 .await;
9546
9547 for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
9548 for method in ["mobkit/status_identity", "mobkit/inspect_identity"] {
9549 let response = Box::pin(handle_console_runtime_rpc(
9550 &runtime,
9551 None,
9552 None,
9553 None,
9554 Some(ConsoleEventStore::new()),
9555 None,
9556 Some(identity_runtime.clone()),
9557 None,
9558 None,
9559 rpc_request_with_params(method, json!({ "identity": requested_identity })),
9560 true,
9561 ))
9562 .await;
9563 assert_eq!(
9564 response["error"],
9565 Value::Null,
9566 "{method} must use durable registered live binding despite duplicate labels for {requested_identity}: {response:#?}"
9567 );
9568 }
9569 }
9570 let reset_all_response = Box::pin(handle_console_runtime_rpc(
9571 &runtime,
9572 None,
9573 None,
9574 None,
9575 Some(ConsoleEventStore::new()),
9576 None,
9577 Some(identity_runtime.clone()),
9578 None,
9579 None,
9580 rpc_request("mobkit/reset_all"),
9581 true,
9582 ))
9583 .await;
9584 assert_eq!(
9585 reset_all_response["error"],
9586 Value::Null,
9587 "reset_all must also prefer the durable registered live binding despite duplicate labels: {reset_all_response:#?}"
9588 );
9589 assert!(
9590 reset_all_response["result"]["failed"]
9591 .as_array()
9592 .is_some_and(Vec::is_empty),
9593 "reset_all should not report duplicate-label failure for durable registered binding: {reset_all_response:#?}"
9594 );
9595
9596 let _ = runtime.handle().stop().await;
9597 Ok(())
9598 }
9599
9600 #[tokio::test]
9601 async fn console_runtime_identity_controls_reject_wrong_projected_live_only_alias()
9602 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9603 let (_temp_dir, runtime) =
9604 build_empty_console_test_runtime("console-identity-wrong-projected-live-only").await?;
9605
9606 let mut labels = BTreeMap::new();
9607 labels.insert("agent_identity".to_string(), "other:singleton".to_string());
9608 runtime
9609 .handle()
9610 .spawn_spec(
9611 SpawnMemberSpec::from_wire(
9612 "worker".to_string(),
9613 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
9616 Some("You are a wrong-projected Review Agent.".into()),
9617 None,
9618 None,
9619 )
9620 .with_labels(labels),
9621 )
9622 .await?;
9623
9624 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
9625 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
9626 lease_provider: Arc::new(LocalLeaseProvider::new()),
9627 runtime_instance_id: "console-identity-wrong-projected-live-only".to_string(),
9628 has_runtime_store: true,
9629 durability_policy: DurabilityPolicy::SyncWriteThrough,
9630 bridge: None,
9631 default_timeout: None,
9632 }));
9633 let identity = AgentIdentity::parse("review:singleton")?;
9634 let record = ContinuityRecord {
9635 identity: identity.clone(),
9636 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
9637 session_id: meerkat_core::types::SessionId::new(),
9638 generation: ContinuityGeneration::new(0),
9639 checkpoint_version: CheckpointVersion::new(0),
9640 };
9641 identity_runtime
9642 .register(
9643 DurableAgentSpec {
9644 identity: identity.clone(),
9645 profile: ProfileName::from("worker"),
9646 addressability: AgentAddressability::Addressable,
9647 display_name: None,
9648 labels: BTreeMap::new(),
9649 context: None,
9650 additional_instructions: Vec::new(),
9651 initial_message: None,
9652 runtime_mode_override: None,
9653 backend: None,
9654 binding: None,
9655 },
9656 IdentityLifecycleState::Active,
9657 Some(record),
9658 Some(LeaseGrant {
9659 identity,
9660 fencing_token: FencingToken::new(1),
9661 ttl: Duration::from_mins(1),
9662 }),
9663 )
9664 .await;
9665
9666 for method in [
9667 "mobkit/status_identity",
9668 "mobkit/inspect_identity",
9669 "mobkit/retire",
9670 ] {
9671 let response = Box::pin(handle_console_runtime_rpc(
9672 &runtime,
9673 None,
9674 None,
9675 None,
9676 Some(ConsoleEventStore::new()),
9677 None,
9678 Some(identity_runtime.clone()),
9679 None,
9680 None,
9681 rpc_request_with_params(method, json!({ "identity": "other:singleton" })),
9682 true,
9683 ))
9684 .await;
9685 assert_ne!(
9686 response["error"],
9687 Value::Null,
9688 "{method} must reject wrong-projected live-only alias"
9689 );
9690 assert_eq!(
9691 response["error"]["data"]["kind"],
9692 json!("stale_live_identity_alias"),
9693 "unexpected response for {method}: {response:#?}"
9694 );
9695 }
9696 assert!(
9697 runtime
9698 .handle()
9699 .get_member(&crate::member_comms_id::mob_member_id(
9700 "rt:review:singleton:0"
9701 ))
9702 .await
9703 .ok()
9704 .flatten()
9705 .is_some(),
9706 "wrong-projected durable runtime member must not be retired through projected alias"
9707 );
9708
9709 let _ = runtime.handle().stop().await;
9710 Ok(())
9711 }
9712
9713 #[derive(Debug)]
9714 struct HideIdentityPolicy(&'static str);
9715
9716 impl ConsoleVisibilityPolicy for HideIdentityPolicy {
9717 fn identity_visible(&self, record: &ConsoleIdentityRecord) -> bool {
9718 record.identity != self.0
9719 }
9720 }
9721
9722 #[derive(Debug)]
9723 struct HideMemberPolicy(&'static str);
9724
9725 impl ConsoleVisibilityPolicy for HideMemberPolicy {
9726 fn member_visible(&self, member: &ConsoleMember) -> bool {
9727 member.agent_identity != self.0
9728 && member
9729 .labels
9730 .get("agent_identity")
9731 .is_none_or(|identity| identity != self.0)
9732 }
9733
9734 fn identity_visible(&self, record: &ConsoleIdentityRecord) -> bool {
9735 record.runtime_member_id != self.0
9736 }
9737 }
9738
9739 #[derive(Debug)]
9740 struct HideOnlyMemberPolicy(&'static str);
9741
9742 impl ConsoleVisibilityPolicy for HideOnlyMemberPolicy {
9743 fn member_visible(&self, member: &ConsoleMember) -> bool {
9744 member.agent_identity != self.0
9745 }
9746 }
9747
9748 #[tokio::test]
9749 async fn console_runtime_identity_controls_respect_visibility_policy()
9750 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9751 let (_temp_dir, runtime) =
9752 build_empty_console_test_runtime("console-identity-hidden-controls").await?;
9753 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
9754 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
9755 lease_provider: Arc::new(LocalLeaseProvider::new()),
9756 runtime_instance_id: "console-identity-hidden-controls".to_string(),
9757 has_runtime_store: true,
9758 durability_policy: DurabilityPolicy::SyncWriteThrough,
9759 bridge: None,
9760 default_timeout: None,
9761 }));
9762 let identity = AgentIdentity::parse("review:singleton")?;
9763 let record = ContinuityRecord {
9764 identity: identity.clone(),
9765 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
9766 session_id: meerkat_core::types::SessionId::new(),
9767 generation: ContinuityGeneration::new(0),
9768 checkpoint_version: CheckpointVersion::new(0),
9769 };
9770 identity_runtime
9771 .register(
9772 DurableAgentSpec {
9773 identity: identity.clone(),
9774 profile: ProfileName::from("worker"),
9775 addressability: AgentAddressability::Addressable,
9776 display_name: None,
9777 labels: BTreeMap::new(),
9778 context: None,
9779 additional_instructions: Vec::new(),
9780 initial_message: None,
9781 runtime_mode_override: None,
9782 backend: None,
9783 binding: None,
9784 },
9785 IdentityLifecycleState::Active,
9786 Some(record),
9787 Some(LeaseGrant {
9788 identity: identity.clone(),
9789 fencing_token: FencingToken::new(7),
9790 ttl: Duration::from_mins(1),
9791 }),
9792 )
9793 .await;
9794
9795 for method in [
9796 "mobkit/status_identity",
9797 "mobkit/inspect_identity",
9798 "mobkit/retire",
9799 "mobkit/respawn",
9800 "mobkit/reset",
9801 "mobkit/delete_identity",
9802 ] {
9803 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
9804 &runtime,
9805 None,
9806 None,
9807 None,
9808 Some(ConsoleEventStore::new()),
9809 None,
9810 Some(identity_runtime.clone()),
9811 None,
9812 None,
9813 &HideIdentityPolicy("review:singleton"),
9814 rpc_request_with_params(method, json!({ "identity": "review:singleton" })),
9815 true,
9816 false,
9817 None,
9818 None,
9819 None,
9820 None,
9821 ))
9822 .await;
9823 assert_ne!(
9824 response["error"],
9825 Value::Null,
9826 "{method} must reject hidden durable identity"
9827 );
9828 assert_eq!(
9829 response["error"]["data"]["kind"],
9830 json!("identity_hidden_by_policy"),
9831 "unexpected hidden response for {method}: {response:#?}"
9832 );
9833 }
9834 identity_runtime
9835 .status(&AgentIdentity::parse("review:singleton")?)
9836 .await
9837 .expect("hidden control RPCs must not mutate the durable identity");
9838
9839 let _ = runtime.handle().stop().await;
9840 Ok(())
9841 }
9842
9843 #[tokio::test]
9844 async fn console_runtime_durable_identity_controls_reject_hidden_bound_member()
9845 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9846 let (_temp_dir, runtime) =
9847 build_empty_console_test_runtime("console-durable-hidden-bound-member").await?;
9848 runtime
9849 .handle()
9850 .spawn_spec(SpawnMemberSpec::from_wire(
9851 "worker".to_string(),
9852 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
9855 Some("You are the live Review Agent.".into()),
9856 None,
9857 None,
9858 ))
9859 .await?;
9860
9861 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
9862 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
9863 lease_provider: Arc::new(LocalLeaseProvider::new()),
9864 runtime_instance_id: "console-durable-hidden-bound-member".to_string(),
9865 has_runtime_store: true,
9866 durability_policy: DurabilityPolicy::SyncWriteThrough,
9867 bridge: None,
9868 default_timeout: None,
9869 }));
9870 let identity = AgentIdentity::parse("review:singleton")?;
9871 let record = ContinuityRecord {
9872 identity: identity.clone(),
9873 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
9874 session_id: meerkat_core::types::SessionId::new(),
9875 generation: ContinuityGeneration::new(0),
9876 checkpoint_version: CheckpointVersion::new(0),
9877 };
9878 identity_runtime
9879 .register(
9880 DurableAgentSpec {
9881 identity: identity.clone(),
9882 profile: ProfileName::from("worker"),
9883 addressability: AgentAddressability::Addressable,
9884 display_name: None,
9885 labels: BTreeMap::new(),
9886 context: None,
9887 additional_instructions: Vec::new(),
9888 initial_message: None,
9889 runtime_mode_override: None,
9890 backend: None,
9891 binding: None,
9892 },
9893 IdentityLifecycleState::Active,
9894 Some(record),
9895 Some(LeaseGrant {
9896 identity: identity.clone(),
9897 fencing_token: FencingToken::new(7),
9898 ttl: Duration::from_mins(1),
9899 }),
9900 )
9901 .await;
9902
9903 for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
9904 for method in [
9905 "mobkit/status_identity",
9906 "mobkit/inspect_identity",
9907 "mobkit/retire",
9908 "mobkit/respawn",
9909 "mobkit/reset",
9910 "mobkit/delete_identity",
9911 ] {
9912 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
9913 &runtime,
9914 None,
9915 None,
9916 None,
9917 Some(ConsoleEventStore::new()),
9918 None,
9919 Some(identity_runtime.clone()),
9920 None,
9921 None,
9922 &HideOnlyMemberPolicy("rt:review:singleton:0"),
9923 rpc_request_with_params(method, json!({ "identity": requested_identity })),
9924 true,
9925 false,
9926 None,
9927 None,
9928 None,
9929 None,
9930 ))
9931 .await;
9932 assert_eq!(
9933 response["error"]["data"]["kind"],
9934 json!("identity_hidden_by_policy"),
9935 "durable {method} must reject hidden bound live member for {requested_identity}: {response:#?}"
9936 );
9937 }
9938 }
9939
9940 let _ = runtime.handle().stop().await;
9941 Ok(())
9942 }
9943
9944 #[tokio::test]
9945 async fn console_runtime_live_only_identity_controls_respect_visibility_policy()
9946 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
9947 let (_temp_dir, runtime) =
9948 build_empty_console_test_runtime("console-live-only-hidden-controls").await?;
9949
9950 let mut labels = BTreeMap::new();
9951 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
9952 runtime
9953 .handle()
9954 .spawn_spec(
9955 SpawnMemberSpec::from_wire(
9956 "worker".to_string(),
9957 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
9960 Some("You are the live Review Agent.".into()),
9961 None,
9962 None,
9963 )
9964 .with_labels(labels),
9965 )
9966 .await?;
9967
9968 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
9969 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
9970 lease_provider: Arc::new(LocalLeaseProvider::new()),
9971 runtime_instance_id: "console-live-only-hidden-controls".to_string(),
9972 has_runtime_store: true,
9973 durability_policy: DurabilityPolicy::SyncWriteThrough,
9974 bridge: None,
9975 default_timeout: None,
9976 }));
9977
9978 for method in [
9979 "mobkit/status_identity",
9980 "mobkit/inspect_identity",
9981 "mobkit/retire",
9982 "mobkit/respawn",
9983 "mobkit/reset",
9984 "mobkit/delete_identity",
9985 ] {
9986 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
9987 &runtime,
9988 None,
9989 None,
9990 None,
9991 Some(ConsoleEventStore::new()),
9992 None,
9993 Some(identity_runtime.clone()),
9994 None,
9995 None,
9996 &HideMemberPolicy("rt:review:singleton:0"),
9997 rpc_request_with_params(method, json!({ "identity": "review:singleton" })),
9998 true,
9999 false,
10000 None,
10001 None,
10002 None,
10003 None,
10004 ))
10005 .await;
10006 assert_ne!(
10007 response["error"],
10008 Value::Null,
10009 "{method} must reject hidden live-only identity"
10010 );
10011 assert_eq!(
10012 response["error"]["data"]["kind"],
10013 json!("identity_hidden_by_policy"),
10014 "unexpected hidden live-only response for {method}: {response:#?}"
10015 );
10016 }
10017 assert!(
10018 runtime
10019 .handle()
10020 .get_member(&crate::member_comms_id::mob_member_id(
10021 "rt:review:singleton:0"
10022 ))
10023 .await
10024 .ok()
10025 .flatten()
10026 .is_some(),
10027 "hidden live-only controls must not mutate the live member"
10028 );
10029
10030 let _ = runtime.handle().stop().await;
10031 Ok(())
10032 }
10033
10034 #[tokio::test]
10035 async fn console_runtime_reset_live_only_alias_without_session_bridge_uses_live_fallback()
10036 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10037 let (_temp_dir, runtime) =
10038 build_empty_console_test_runtime("console-reset-live-only-no-bridge").await?;
10039
10040 let mut labels = BTreeMap::new();
10041 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
10042 runtime
10043 .handle()
10044 .spawn_spec(
10045 SpawnMemberSpec::from_wire(
10046 "worker".to_string(),
10047 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10050 Some("You are the live Review Agent.".into()),
10051 None,
10052 None,
10053 )
10054 .with_labels(labels),
10055 )
10056 .await?;
10057
10058 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10059 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10060 lease_provider: Arc::new(LocalLeaseProvider::new()),
10061 runtime_instance_id: "console-reset-live-only-no-bridge".to_string(),
10062 has_runtime_store: true,
10063 durability_policy: DurabilityPolicy::SyncWriteThrough,
10064 bridge: None,
10065 default_timeout: None,
10066 }));
10067
10068 let response = Box::pin(handle_console_runtime_rpc(
10069 &runtime,
10070 None,
10071 None,
10072 None,
10073 Some(ConsoleEventStore::new()),
10074 None,
10075 Some(identity_runtime),
10076 None,
10077 None,
10078 rpc_request_with_params("mobkit/reset", json!({ "identity": "review:singleton" })),
10079 true,
10080 ))
10081 .await;
10082 assert_eq!(
10083 response["error"],
10084 Value::Null,
10085 "live-only reset should use live fallback instead of requiring session bridge: {response:#?}"
10086 );
10087 assert_eq!(response["result"]["identity"], json!("review:singleton"));
10088
10089 let _ = runtime.handle().stop().await;
10090 Ok(())
10091 }
10092
10093 #[tokio::test]
10094 async fn reset_all_rejects_registered_runtime_projected_under_wrong_identity()
10095 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10096 let (_temp_dir, runtime) =
10097 build_empty_console_test_runtime("console-reset-all-stale-projection").await?;
10098
10099 let mut labels = BTreeMap::new();
10100 labels.insert("agent_identity".to_string(), "other:singleton".to_string());
10101 runtime
10102 .handle()
10103 .spawn_spec(
10104 SpawnMemberSpec::from_wire(
10105 "worker".to_string(),
10106 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10109 Some("You are a mislabeled Review Agent.".into()),
10110 None,
10111 None,
10112 )
10113 .with_labels(labels),
10114 )
10115 .await?;
10116
10117 let store = Arc::new(LocalContinuityStore::in_memory()?);
10118 let lease_provider = Arc::new(LocalLeaseProvider::new());
10119 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10120 continuity_store: store.clone(),
10121 lease_provider: lease_provider.clone(),
10122 runtime_instance_id: "test-runtime".to_string(),
10123 has_runtime_store: true,
10124 durability_policy: DurabilityPolicy::SyncWriteThrough,
10125 bridge: None,
10126 default_timeout: None,
10127 }));
10128 let identity = AgentIdentity::parse("review:singleton")?;
10129 let record = ContinuityRecord {
10130 identity: identity.clone(),
10131 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
10132 session_id: meerkat_core::types::SessionId::new(),
10133 generation: ContinuityGeneration::new(0),
10134 checkpoint_version: CheckpointVersion::new(0),
10135 };
10136 let grants = lease_provider
10137 .acquire_leases(std::slice::from_ref(&identity), "test-runtime")
10138 .await?;
10139 let grant = match grants.get(&identity).cloned() {
10140 Some(LeaseAcquireResult::Acquired(grant)) => grant,
10141 other => return Err(format!("expected acquired lease, got {other:?}").into()),
10142 };
10143 store
10144 .upsert_continuity_record(&record, grant.fencing_token)
10145 .await?;
10146 identity_runtime
10147 .register(
10148 DurableAgentSpec {
10149 identity: identity.clone(),
10150 profile: ProfileName::from("worker"),
10151 addressability: AgentAddressability::Addressable,
10152 display_name: None,
10153 labels: BTreeMap::new(),
10154 context: None,
10155 additional_instructions: Vec::new(),
10156 initial_message: None,
10157 runtime_mode_override: None,
10158 backend: None,
10159 binding: None,
10160 },
10161 IdentityLifecycleState::Active,
10162 Some(record),
10163 Some(grant),
10164 )
10165 .await;
10166
10167 let response = Box::pin(handle_console_runtime_rpc(
10168 &runtime,
10169 None,
10170 None,
10171 None,
10172 Some(ConsoleEventStore::new()),
10173 None,
10174 Some(identity_runtime),
10175 None,
10176 None,
10177 rpc_request("mobkit/reset_all"),
10178 true,
10179 ))
10180 .await;
10181 assert_ne!(response["error"], Value::Null);
10182 let failed = response["error"]["data"]["failed"]
10183 .as_array()
10184 .expect("reset_all should report failed identities");
10185 let stale_failure = failed
10186 .iter()
10187 .find(|failure| failure["identity"] == json!("review:singleton"))
10188 .expect("review identity should fail stale alias validation");
10189 assert_eq!(
10190 stale_failure["kind"],
10191 json!("stale_live_identity_alias"),
10192 "unexpected reset_all response: {response:#?}"
10193 );
10194 assert!(
10195 stale_failure["error"]
10196 .as_str()
10197 .unwrap_or_default()
10198 .contains("projects identity other:singleton"),
10199 "unexpected stale failure: {stale_failure:#?}"
10200 );
10201 let retired = response["error"]["data"]["retired_delegates"]
10202 .as_array()
10203 .expect("reset_all should return retired delegates");
10204 assert!(
10205 !retired
10206 .iter()
10207 .any(|entry| entry["identity"] == json!("other:singleton")),
10208 "wrong-projected live alias must not be destructively retired before stale validation; response: {response:#?}"
10209 );
10210 assert!(
10211 runtime
10212 .handle()
10213 .get_member(&crate::member_comms_id::mob_member_id(
10214 "rt:review:singleton:0"
10215 ))
10216 .await
10217 .ok()
10218 .flatten()
10219 .is_some(),
10220 "wrong-projected durable runtime member must remain present after reset_all rejection"
10221 );
10222
10223 let _ = runtime.handle().stop().await;
10224 Ok(())
10225 }
10226
10227 #[tokio::test]
10228 async fn reset_all_respects_console_visibility_policy_for_live_members()
10229 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10230 let (_temp_dir, runtime) =
10231 build_empty_console_test_runtime("console-reset-all-hidden-live").await?;
10232
10233 runtime
10234 .handle()
10235 .spawn_spec(
10236 SpawnMemberSpec::from_wire(
10237 "worker".to_string(),
10238 crate::member_comms_id::mob_member_id_str("hidden:singleton").into_owned(),
10241 Some("You are hidden from console lifecycle controls.".into()),
10242 None,
10243 None,
10244 )
10245 .with_labels(BTreeMap::from([(
10246 "agent_identity".to_string(),
10247 "hidden:singleton".to_string(),
10248 )])),
10249 )
10250 .await?;
10251
10252 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
10253 &runtime,
10254 None,
10255 None,
10256 None,
10257 Some(ConsoleEventStore::new()),
10258 None,
10259 None,
10260 None,
10261 None,
10262 &HideMemberPolicy("hidden:singleton"),
10263 rpc_request("mobkit/reset_all"),
10264 true,
10265 false,
10266 None,
10267 None,
10268 None,
10269 None,
10270 ))
10271 .await;
10272 assert_eq!(
10273 response["error"],
10274 Value::Null,
10275 "hidden live member should be outside reset_all target set: {response:#?}"
10276 );
10277 assert!(
10278 runtime
10279 .handle()
10280 .get_member(&crate::member_comms_id::mob_member_id("hidden:singleton"))
10281 .await
10282 .ok()
10283 .flatten()
10284 .is_some(),
10285 "reset_all must not retire hidden live members"
10286 );
10287
10288 let _ = runtime.handle().stop().await;
10289 Ok(())
10290 }
10291
10292 #[tokio::test]
10293 async fn reset_all_skips_durable_identity_with_hidden_bound_member()
10294 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10295 let (_temp_dir, runtime) =
10296 build_empty_console_test_runtime("console-reset-all-hidden-durable-bound").await?;
10297 runtime
10298 .handle()
10299 .spawn_spec(SpawnMemberSpec::from_wire(
10300 "worker".to_string(),
10301 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10304 Some("You are the hidden Review Agent.".into()),
10305 None,
10306 None,
10307 ))
10308 .await?;
10309
10310 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10311 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10312 lease_provider: Arc::new(LocalLeaseProvider::new()),
10313 runtime_instance_id: "console-reset-all-hidden-durable-bound".to_string(),
10314 has_runtime_store: true,
10315 durability_policy: DurabilityPolicy::SyncWriteThrough,
10316 bridge: None,
10317 default_timeout: None,
10318 }));
10319 let identity = AgentIdentity::parse("review:singleton")?;
10320 identity_runtime
10321 .register(
10322 DurableAgentSpec {
10323 identity: identity.clone(),
10324 profile: ProfileName::from("worker"),
10325 addressability: AgentAddressability::Addressable,
10326 display_name: None,
10327 labels: BTreeMap::new(),
10328 context: None,
10329 additional_instructions: Vec::new(),
10330 initial_message: None,
10331 runtime_mode_override: None,
10332 backend: None,
10333 binding: None,
10334 },
10335 IdentityLifecycleState::Active,
10336 Some(ContinuityRecord {
10337 identity: identity.clone(),
10338 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
10339 session_id: meerkat_core::types::SessionId::new(),
10340 generation: ContinuityGeneration::new(0),
10341 checkpoint_version: CheckpointVersion::new(0),
10342 }),
10343 Some(LeaseGrant {
10344 identity: identity.clone(),
10345 fencing_token: FencingToken::new(9),
10346 ttl: Duration::from_mins(1),
10347 }),
10348 )
10349 .await;
10350
10351 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
10352 &runtime,
10353 None,
10354 None,
10355 None,
10356 Some(ConsoleEventStore::new()),
10357 None,
10358 Some(identity_runtime.clone()),
10359 None,
10360 None,
10361 &HideOnlyMemberPolicy("rt:review:singleton:0"),
10362 rpc_request("mobkit/reset_all"),
10363 true,
10364 false,
10365 None,
10366 None,
10367 None,
10368 None,
10369 ))
10370 .await;
10371 assert_eq!(
10372 response["error"],
10373 Value::Null,
10374 "hidden durable bound member should be outside reset_all target set: {response:#?}"
10375 );
10376 assert_eq!(
10377 identity_runtime.status(&identity).await?.state,
10378 IdentityLifecycleState::Active
10379 );
10380
10381 let _ = runtime.handle().stop().await;
10382 Ok(())
10383 }
10384
10385 #[tokio::test]
10386 async fn identity_lifecycle_cleanup_skips_hidden_projected_duplicates()
10387 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10388 let (_temp_dir, runtime) =
10389 build_empty_console_test_runtime("console-hidden-stale-duplicate-cleanup").await?;
10390 for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
10391 runtime
10392 .handle()
10393 .spawn_spec(
10394 SpawnMemberSpec::from_wire(
10395 "worker".to_string(),
10396 crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
10399 Some("You are a Review Agent candidate.".into()),
10400 None,
10401 None,
10402 )
10403 .with_labels(BTreeMap::from([(
10404 "agent_identity".to_string(),
10405 "review:singleton".to_string(),
10406 )])),
10407 )
10408 .await?;
10409 }
10410
10411 let store = Arc::new(LocalContinuityStore::in_memory()?);
10412 let lease_provider = Arc::new(LocalLeaseProvider::new());
10413 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10414 continuity_store: store.clone(),
10415 lease_provider: lease_provider.clone(),
10416 runtime_instance_id: "console-hidden-stale-duplicate-cleanup".to_string(),
10417 has_runtime_store: true,
10418 durability_policy: DurabilityPolicy::SyncWriteThrough,
10419 bridge: None,
10420 default_timeout: None,
10421 }));
10422 let identity = AgentIdentity::parse("review:singleton")?;
10423 let record = ContinuityRecord {
10424 identity: identity.clone(),
10425 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
10426 session_id: runtime
10427 .handle()
10428 .resolve_bridge_session_id_observation(&crate::member_comms_id::mob_member_id(
10429 "rt:review:singleton:0",
10430 ))
10431 .await
10432 .unwrap_or_else(meerkat_core::types::SessionId::new),
10433 generation: ContinuityGeneration::new(0),
10434 checkpoint_version: CheckpointVersion::new(0),
10435 };
10436 let grants = lease_provider
10437 .acquire_leases(
10438 std::slice::from_ref(&identity),
10439 "console-hidden-stale-duplicate-cleanup",
10440 )
10441 .await?;
10442 let grant = match grants.get(&identity).cloned() {
10443 Some(LeaseAcquireResult::Acquired(grant)) => grant,
10444 other => return Err(format!("expected acquired lease, got {other:?}").into()),
10445 };
10446 store
10447 .upsert_continuity_record(&record, grant.fencing_token)
10448 .await?;
10449 identity_runtime
10450 .register(
10451 DurableAgentSpec {
10452 identity: identity.clone(),
10453 profile: ProfileName::from("worker"),
10454 addressability: AgentAddressability::Addressable,
10455 display_name: None,
10456 labels: BTreeMap::new(),
10457 context: None,
10458 additional_instructions: Vec::new(),
10459 initial_message: None,
10460 runtime_mode_override: None,
10461 backend: None,
10462 binding: None,
10463 },
10464 IdentityLifecycleState::Active,
10465 Some(record),
10466 Some(grant),
10467 )
10468 .await;
10469
10470 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
10471 &runtime,
10472 None,
10473 None,
10474 None,
10475 Some(ConsoleEventStore::new()),
10476 None,
10477 Some(identity_runtime),
10478 None,
10479 None,
10480 &HideOnlyMemberPolicy("rt:review:singleton:1"),
10481 rpc_request_with_params("mobkit/retire", json!({ "identity": "review:singleton" })),
10482 true,
10483 false,
10484 None,
10485 None,
10486 None,
10487 None,
10488 ))
10489 .await;
10490 assert_eq!(
10491 response["error"],
10492 Value::Null,
10493 "visible durable retire should succeed without touching hidden duplicate: {response:#?}"
10494 );
10495 assert!(
10496 runtime
10497 .handle()
10498 .get_member(&crate::member_comms_id::mob_member_id(
10499 "rt:review:singleton:1"
10500 ))
10501 .await
10502 .ok()
10503 .flatten()
10504 .is_some(),
10505 "post-mutation stale cleanup must not retire member-hidden projected duplicates"
10506 );
10507
10508 let _ = runtime.handle().stop().await;
10509 Ok(())
10510 }
10511
10512 #[tokio::test]
10513 async fn console_runtime_capabilities_advertise_identity_controls_when_identity_runtime_exists()
10514 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10515 let (_temp_dir, runtime) =
10516 build_empty_console_test_runtime("console-identity-capabilities").await?;
10517 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10518 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10519 lease_provider: Arc::new(LocalLeaseProvider::new()),
10520 runtime_instance_id: "console-identity-capabilities".to_string(),
10521 has_runtime_store: true,
10522 durability_policy: DurabilityPolicy::SyncWriteThrough,
10523 bridge: None,
10524 default_timeout: None,
10525 }));
10526
10527 let response = Box::pin(handle_console_runtime_rpc(
10528 &runtime,
10529 None,
10530 None,
10531 None,
10532 None,
10533 None,
10534 Some(identity_runtime.clone()),
10535 None,
10536 None,
10537 rpc_request("mobkit/capabilities"),
10538 true,
10539 ))
10540 .await;
10541
10542 assert_eq!(response["error"], Value::Null, "{response:#?}");
10543 let methods = response["result"]["methods"]
10544 .as_array()
10545 .ok_or("capabilities methods should be an array")?;
10546 for method in [
10547 "mobkit/status_identity",
10548 "mobkit/inspect_identity",
10549 "mobkit/respawn",
10550 "mobkit/reset",
10551 "mobkit/delete_identity",
10552 ] {
10553 assert!(
10554 methods.iter().any(|candidate| candidate == method),
10555 "identity runtime capabilities should advertise {method}: {methods:#?}"
10556 );
10557 }
10558 for method in crate::rpc::MOBPACK_AUTHORING_METHODS {
10559 assert!(
10560 !methods.iter().any(|candidate| candidate == method),
10561 "console runtime capabilities must not advertise flow-editor method {method}: {methods:#?}"
10562 );
10563 }
10564
10565 let mobpack_response = Box::pin(handle_console_runtime_rpc(
10566 &runtime,
10567 None,
10568 None,
10569 None,
10570 None,
10571 None,
10572 Some(identity_runtime),
10573 None,
10574 None,
10575 rpc_request("mobkit/mobpacks/schema"),
10576 true,
10577 ))
10578 .await;
10579 assert_eq!(
10580 mobpack_response["error"]["code"],
10581 json!(-32601),
10582 "console runtime RPC must not handle flow-editor authoring methods: {mobpack_response:#?}"
10583 );
10584
10585 let _ = runtime.handle().stop().await;
10586 Ok(())
10587 }
10588
10589 #[tokio::test]
10590 async fn console_runtime_capabilities_advertise_agent_memory_with_read_only_gating()
10591 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10592 let (_temp_dir, runtime) =
10593 build_empty_console_test_runtime("console-agent-memory-capabilities").await?;
10594 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10595 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10596 lease_provider: Arc::new(LocalLeaseProvider::new()),
10597 runtime_instance_id: "console-agent-memory-capabilities".to_string(),
10598 has_runtime_store: true,
10599 durability_policy: DurabilityPolicy::SyncWriteThrough,
10600 bridge: None,
10601 default_timeout: None,
10602 }));
10603 let memory_dir = tempfile::tempdir()?;
10604 let memory_store = Arc::new(MarkdownAgentMemoryStore::open(memory_dir.path())?);
10605 identity_runtime
10606 .set_agent_memory(Some(AgentMemoryRuntimeInjector::new(
10607 memory_store,
10608 AgentMemoryConfig::default(),
10609 )))
10610 .await;
10611
10612 let response = Box::pin(handle_console_runtime_rpc(
10613 &runtime,
10614 None,
10615 None,
10616 None,
10617 None,
10618 None,
10619 Some(identity_runtime.clone()),
10620 None,
10621 None,
10622 rpc_request("mobkit/capabilities"),
10623 true,
10624 ))
10625 .await;
10626 assert_eq!(response["error"], Value::Null, "{response:#?}");
10627 let methods = response["result"]["methods"]
10628 .as_array()
10629 .ok_or("capabilities methods should be an array")?;
10630 for method in [
10631 "mobkit/agent_memory/recall",
10632 "mobkit/agent_memory/remember",
10633 "mobkit/agent_memory/forget",
10634 ] {
10635 assert!(
10636 methods.iter().any(|candidate| candidate == method),
10637 "authenticated capabilities should advertise {method}: {methods:#?}"
10638 );
10639 }
10640
10641 let read_only_response = Box::pin(handle_console_runtime_rpc(
10642 &runtime,
10643 None,
10644 None,
10645 None,
10646 None,
10647 None,
10648 Some(identity_runtime),
10649 None,
10650 None,
10651 rpc_request("mobkit/capabilities"),
10652 false,
10653 ))
10654 .await;
10655 assert_eq!(
10656 read_only_response["error"],
10657 Value::Null,
10658 "{read_only_response:#?}"
10659 );
10660 let read_only_methods = read_only_response["result"]["methods"]
10661 .as_array()
10662 .ok_or("read-only capabilities methods should be an array")?;
10663 assert!(
10664 read_only_methods
10665 .iter()
10666 .any(|candidate| candidate == "mobkit/agent_memory/recall"),
10667 "read-only capabilities should keep recall: {read_only_methods:#?}"
10668 );
10669 for method in ["mobkit/agent_memory/remember", "mobkit/agent_memory/forget"] {
10670 assert!(
10671 !read_only_methods
10672 .iter()
10673 .any(|candidate| candidate == method),
10674 "read-only capabilities must omit {method}: {read_only_methods:#?}"
10675 );
10676 }
10677
10678 let _ = runtime.handle().stop().await;
10679 Ok(())
10680 }
10681
10682 #[tokio::test]
10683 async fn console_runtime_agent_memory_methods_round_trip()
10684 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10685 let (_temp_dir, runtime) =
10686 build_empty_console_test_runtime("console-agent-memory-round-trip").await?;
10687 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10688 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10689 lease_provider: Arc::new(LocalLeaseProvider::new()),
10690 runtime_instance_id: "console-agent-memory-round-trip".to_string(),
10691 has_runtime_store: true,
10692 durability_policy: DurabilityPolicy::SyncWriteThrough,
10693 bridge: None,
10694 default_timeout: None,
10695 }));
10696 let identity = AgentIdentity::parse("identity:memory-console")?;
10697 identity_runtime
10698 .register(
10699 DurableAgentSpec {
10700 identity: identity.clone(),
10701 profile: ProfileName::from("default"),
10702 addressability: AgentAddressability::Addressable,
10703 display_name: None,
10704 labels: BTreeMap::new(),
10705 context: None,
10706 additional_instructions: Vec::new(),
10707 initial_message: None,
10708 runtime_mode_override: None,
10709 backend: None,
10710 binding: None,
10711 },
10712 IdentityLifecycleState::Active,
10713 None,
10714 None,
10715 )
10716 .await;
10717 let memory_dir = tempfile::tempdir()?;
10718 let memory_store = Arc::new(MarkdownAgentMemoryStore::open(memory_dir.path())?);
10719 identity_runtime
10720 .set_agent_memory(Some(AgentMemoryRuntimeInjector::new(
10721 memory_store,
10722 AgentMemoryConfig::default(),
10723 )))
10724 .await;
10725
10726 let remember = Box::pin(handle_console_runtime_rpc(
10727 &runtime,
10728 None,
10729 None,
10730 None,
10731 None,
10732 None,
10733 Some(identity_runtime.clone()),
10734 None,
10735 None,
10736 rpc_request_with_params(
10737 "mobkit/agent_memory/remember",
10738 json!({
10739 "identity": "identity:memory-console",
10740 "title": "Console memory token",
10741 "body": "The console memory token is CONSOLE-MEM-17.",
10742 "tags": ["console", "memory"]
10743 }),
10744 ),
10745 true,
10746 ))
10747 .await;
10748 assert_eq!(remember["error"], Value::Null, "{remember:#?}");
10749 let memory_id = remember["result"]["memory_id"]
10750 .as_str()
10751 .ok_or("remember result should include memory_id")?
10752 .to_string();
10753
10754 let recall = Box::pin(handle_console_runtime_rpc(
10755 &runtime,
10756 None,
10757 None,
10758 None,
10759 None,
10760 None,
10761 Some(identity_runtime.clone()),
10762 None,
10763 None,
10764 rpc_request_with_params(
10765 "mobkit/agent_memory/recall",
10766 json!({
10767 "identity": "identity:memory-console",
10768 "selection": "contextual",
10769 "query_text": "Where is CONSOLE-MEM-17?",
10770 "query_terms": ["CONSOLE-MEM-17"]
10771 }),
10772 ),
10773 false,
10774 ))
10775 .await;
10776 assert_eq!(recall["error"], Value::Null, "{recall:#?}");
10777 assert_eq!(
10778 recall["result"]["records"]
10779 .as_array()
10780 .map(std::vec::Vec::len),
10781 Some(1)
10782 );
10783 assert_eq!(
10784 recall["result"]["records"][0]["memory_id"],
10785 json!(memory_id)
10786 );
10787
10788 let read_only_remember = Box::pin(handle_console_runtime_rpc(
10789 &runtime,
10790 None,
10791 None,
10792 None,
10793 None,
10794 None,
10795 Some(identity_runtime.clone()),
10796 None,
10797 None,
10798 rpc_request_with_params(
10799 "mobkit/agent_memory/remember",
10800 json!({
10801 "identity": "identity:memory-console",
10802 "title": "Denied",
10803 "body": "Denied"
10804 }),
10805 ),
10806 false,
10807 ))
10808 .await;
10809 assert_eq!(
10810 read_only_remember["error"]["data"]["kind"],
10811 json!("read_only"),
10812 "{read_only_remember:#?}"
10813 );
10814
10815 let access_controller = AccessController::new(crate::access::AccessControlConfig {
10816 enabled: true,
10817 admins: vec!["admin@example.test".to_string()],
10818 ..crate::access::AccessControlConfig::default()
10819 })?;
10820 let denied_view = access_controller.view_for_subject(Some("viewer@example.test"));
10821 let denied_recall = Box::pin(handle_console_runtime_rpc_with_visibility(
10822 &runtime,
10823 None,
10824 None,
10825 None,
10826 None,
10827 None,
10828 Some(identity_runtime.clone()),
10829 None,
10830 None,
10831 &AllowAllConsoleVisibilityPolicy,
10832 rpc_request_with_params(
10833 "mobkit/agent_memory/recall",
10834 json!({
10835 "identity": "identity:memory-console",
10836 "selection": "always"
10837 }),
10838 ),
10839 true,
10840 false,
10841 None,
10842 Some(&access_controller),
10843 Some(&denied_view),
10844 None,
10845 ))
10846 .await;
10847 assert_eq!(
10848 denied_recall["error"]["data"]["kind"],
10849 json!("access_denied"),
10850 "{denied_recall:#?}"
10851 );
10852 assert_eq!(
10855 denied_recall["error"]["data"]["action"],
10856 json!(ACTION_AGENT_MEMORY_READ),
10857 "{denied_recall:#?}"
10858 );
10859
10860 let send_only_controller = AccessController::new(crate::access::AccessControlConfig {
10861 enabled: true,
10862 admins: vec!["admin@example.test".to_string()],
10863 rules: vec![crate::access::AccessRule {
10864 id: "send-only-memory-console".to_string(),
10865 subjects: vec!["sender@example.test".to_string()],
10866 actions: vec![ACTION_AGENT_SEND.to_string()],
10867 agents: vec!["identity:memory-console".to_string()],
10868 ..crate::access::AccessRule::default()
10869 }],
10870 ..crate::access::AccessControlConfig::default()
10871 })?;
10872 let send_only_view = send_only_controller.view_for_subject(Some("sender@example.test"));
10873 let send_only_remember = Box::pin(handle_console_runtime_rpc_with_visibility(
10874 &runtime,
10875 None,
10876 None,
10877 None,
10878 None,
10879 None,
10880 Some(identity_runtime.clone()),
10881 None,
10882 None,
10883 &AllowAllConsoleVisibilityPolicy,
10884 rpc_request_with_params(
10885 "mobkit/agent_memory/remember",
10886 json!({
10887 "identity": "identity:memory-console",
10888 "title": "Send-only denied",
10889 "body": "Send-only users must not persist durable memory."
10890 }),
10891 ),
10892 true,
10893 false,
10894 None,
10895 Some(&send_only_controller),
10896 Some(&send_only_view),
10897 None,
10898 ))
10899 .await;
10900 assert_eq!(
10901 send_only_remember["error"]["data"]["kind"],
10902 json!("access_denied"),
10903 "{send_only_remember:#?}"
10904 );
10905 assert_eq!(
10906 send_only_remember["error"]["data"]["action"],
10907 json!(ACTION_AGENT_MEMORY_WRITE),
10908 "{send_only_remember:#?}"
10909 );
10910
10911 let send_only_forget = Box::pin(handle_console_runtime_rpc_with_visibility(
10912 &runtime,
10913 None,
10914 None,
10915 None,
10916 None,
10917 None,
10918 Some(identity_runtime.clone()),
10919 None,
10920 None,
10921 &AllowAllConsoleVisibilityPolicy,
10922 rpc_request_with_params(
10923 "mobkit/agent_memory/forget",
10924 json!({
10925 "identity": "identity:memory-console",
10926 "memory_id": memory_id.clone()
10927 }),
10928 ),
10929 true,
10930 false,
10931 None,
10932 Some(&send_only_controller),
10933 Some(&send_only_view),
10934 None,
10935 ))
10936 .await;
10937 assert_eq!(
10938 send_only_forget["error"]["data"]["kind"],
10939 json!("access_denied"),
10940 "{send_only_forget:#?}"
10941 );
10942 assert_eq!(
10943 send_only_forget["error"]["data"]["action"],
10944 json!(ACTION_AGENT_MEMORY_DELETE),
10945 "{send_only_forget:#?}"
10946 );
10947
10948 let wildcard_allow_exact_deny_controller =
10949 AccessController::new(crate::access::AccessControlConfig {
10950 enabled: true,
10951 admins: vec!["admin@example.test".to_string()],
10952 rules: vec![
10953 crate::access::AccessRule {
10954 id: "deny-canonical-memory-console".to_string(),
10955 effect: crate::access::AccessEffect::Deny,
10956 subjects: vec!["wildcard@example.test".to_string()],
10957 actions: vec![
10958 ACTION_AGENT_VIEW.to_string(),
10959 ACTION_AGENT_MEMORY_WRITE.to_string(),
10960 ACTION_AGENT_MEMORY_DELETE.to_string(),
10961 ],
10962 agents: vec!["identity:memory-console".to_string()],
10963 ..crate::access::AccessRule::default()
10964 },
10965 crate::access::AccessRule {
10966 id: "allow-wildcard-memory-console".to_string(),
10967 subjects: vec!["wildcard@example.test".to_string()],
10968 actions: vec![
10969 ACTION_AGENT_VIEW.to_string(),
10970 ACTION_AGENT_MEMORY_WRITE.to_string(),
10971 ACTION_AGENT_MEMORY_DELETE.to_string(),
10972 ],
10973 agents: vec!["*".to_string()],
10974 ..crate::access::AccessRule::default()
10975 },
10976 ],
10977 ..crate::access::AccessControlConfig::default()
10978 })?;
10979 let wildcard_allow_exact_deny_view =
10980 wildcard_allow_exact_deny_controller.view_for_subject(Some("wildcard@example.test"));
10981
10982 let whitespace_denied_remember = Box::pin(handle_console_runtime_rpc_with_visibility(
10983 &runtime,
10984 None,
10985 None,
10986 None,
10987 None,
10988 None,
10989 Some(identity_runtime.clone()),
10990 None,
10991 None,
10992 &AllowAllConsoleVisibilityPolicy,
10993 rpc_request_with_params(
10994 "mobkit/agent_memory/remember",
10995 json!({
10996 "identity": " identity:memory-console ",
10997 "title": "Whitespace denied",
10998 "body": "Whitespace-wrapped identities must be authorized canonically."
10999 }),
11000 ),
11001 true,
11002 false,
11003 None,
11004 Some(&wildcard_allow_exact_deny_controller),
11005 Some(&wildcard_allow_exact_deny_view),
11006 None,
11007 ))
11008 .await;
11009 assert_eq!(
11010 whitespace_denied_remember["error"]["data"]["kind"],
11011 json!("access_denied"),
11012 "{whitespace_denied_remember:#?}"
11013 );
11014 assert_eq!(
11015 whitespace_denied_remember["error"]["data"]["action"],
11016 json!(ACTION_AGENT_MEMORY_WRITE),
11017 "{whitespace_denied_remember:#?}"
11018 );
11019
11020 let whitespace_denied_recall = Box::pin(handle_console_runtime_rpc_with_visibility(
11021 &runtime,
11022 None,
11023 None,
11024 None,
11025 None,
11026 None,
11027 Some(identity_runtime.clone()),
11028 None,
11029 None,
11030 &AllowAllConsoleVisibilityPolicy,
11031 rpc_request_with_params(
11032 "mobkit/agent_memory/recall",
11033 json!({
11034 "identity": " identity:memory-console ",
11035 "selection": "always"
11036 }),
11037 ),
11038 true,
11039 false,
11040 None,
11041 Some(&wildcard_allow_exact_deny_controller),
11042 Some(&wildcard_allow_exact_deny_view),
11043 None,
11044 ))
11045 .await;
11046 assert_eq!(
11047 whitespace_denied_recall["error"]["data"]["kind"],
11048 json!("access_denied"),
11049 "{whitespace_denied_recall:#?}"
11050 );
11051 assert_eq!(
11054 whitespace_denied_recall["error"]["data"]["action"],
11055 json!(ACTION_AGENT_MEMORY_READ),
11056 "{whitespace_denied_recall:#?}"
11057 );
11058
11059 let whitespace_denied_forget = Box::pin(handle_console_runtime_rpc_with_visibility(
11060 &runtime,
11061 None,
11062 None,
11063 None,
11064 None,
11065 None,
11066 Some(identity_runtime.clone()),
11067 None,
11068 None,
11069 &AllowAllConsoleVisibilityPolicy,
11070 rpc_request_with_params(
11071 "mobkit/agent_memory/forget",
11072 json!({
11073 "identity": " identity:memory-console ",
11074 "memory_id": memory_id.clone()
11075 }),
11076 ),
11077 true,
11078 false,
11079 None,
11080 Some(&wildcard_allow_exact_deny_controller),
11081 Some(&wildcard_allow_exact_deny_view),
11082 None,
11083 ))
11084 .await;
11085 assert_eq!(
11086 whitespace_denied_forget["error"]["data"]["kind"],
11087 json!("access_denied"),
11088 "{whitespace_denied_forget:#?}"
11089 );
11090 assert_eq!(
11091 whitespace_denied_forget["error"]["data"]["action"],
11092 json!(ACTION_AGENT_MEMORY_DELETE),
11093 "{whitespace_denied_forget:#?}"
11094 );
11095
11096 let forget = Box::pin(handle_console_runtime_rpc(
11097 &runtime,
11098 None,
11099 None,
11100 None,
11101 None,
11102 None,
11103 Some(identity_runtime.clone()),
11104 None,
11105 None,
11106 rpc_request_with_params(
11107 "mobkit/agent_memory/forget",
11108 json!({
11109 "identity": "identity:memory-console",
11110 "memory_id": memory_id
11111 }),
11112 ),
11113 true,
11114 ))
11115 .await;
11116 assert_eq!(forget["error"], Value::Null, "{forget:#?}");
11117 assert_eq!(forget["result"]["deleted"], json!(true));
11118
11119 let after_forget = Box::pin(handle_console_runtime_rpc(
11120 &runtime,
11121 None,
11122 None,
11123 None,
11124 None,
11125 None,
11126 Some(identity_runtime),
11127 None,
11128 None,
11129 rpc_request_with_params(
11130 "mobkit/agent_memory/recall",
11131 json!({
11132 "identity": "identity:memory-console",
11133 "selection": "always"
11134 }),
11135 ),
11136 false,
11137 ))
11138 .await;
11139 assert_eq!(after_forget["error"], Value::Null, "{after_forget:#?}");
11140 assert_eq!(
11141 after_forget["result"]["records"]
11142 .as_array()
11143 .map(std::vec::Vec::len),
11144 Some(0)
11145 );
11146
11147 let _ = runtime.handle().stop().await;
11148 Ok(())
11149 }
11150
11151 #[tokio::test]
11152 async fn console_runtime_capabilities_advertise_respawn_and_reset_without_identity_runtime()
11153 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11154 let (_temp_dir, runtime) =
11155 build_empty_console_test_runtime("console-plain-lifecycle-capabilities").await?;
11156
11157 let response = Box::pin(handle_console_runtime_rpc(
11161 &runtime,
11162 None,
11163 None,
11164 None,
11165 None,
11166 None,
11167 None,
11168 None,
11169 None,
11170 rpc_request("mobkit/capabilities"),
11171 true,
11172 ))
11173 .await;
11174 assert_eq!(response["error"], Value::Null, "{response:#?}");
11175 let methods = response["result"]["methods"]
11176 .as_array()
11177 .ok_or("capabilities methods should be an array")?;
11178 for method in ["mobkit/retire", "mobkit/respawn", "mobkit/reset"] {
11179 assert!(
11180 methods.iter().any(|candidate| candidate == method),
11181 "plain runtime capabilities should advertise {method}: {methods:#?}"
11182 );
11183 }
11184 assert!(
11185 !methods
11186 .iter()
11187 .any(|candidate| candidate == "mobkit/delete_identity"),
11188 "delete_identity requires an identity runtime: {methods:#?}"
11189 );
11190
11191 let read_only_response = Box::pin(handle_console_runtime_rpc(
11194 &runtime,
11195 None,
11196 None,
11197 None,
11198 None,
11199 None,
11200 None,
11201 None,
11202 None,
11203 rpc_request("mobkit/capabilities"),
11204 false,
11205 ))
11206 .await;
11207 assert_eq!(
11208 read_only_response["error"],
11209 Value::Null,
11210 "{read_only_response:#?}"
11211 );
11212 let read_only_methods = read_only_response["result"]["methods"]
11213 .as_array()
11214 .ok_or("capabilities methods should be an array")?;
11215 for method in ["mobkit/retire", "mobkit/respawn", "mobkit/reset"] {
11216 assert!(
11217 !read_only_methods
11218 .iter()
11219 .any(|candidate| candidate == method),
11220 "unauthenticated capabilities must omit {method}: {read_only_methods:#?}"
11221 );
11222 }
11223
11224 let _ = runtime.handle().stop().await;
11225 Ok(())
11226 }
11227
11228 #[tokio::test]
11229 async fn console_aggregator_rpc_does_not_expose_flow_editor_authoring_methods() {
11230 let response = Box::pin(handle_console_aggregator_rpc(
11231 None,
11232 rpc_request("mobkit/capabilities"),
11233 true,
11234 false,
11235 None,
11236 None,
11237 ))
11238 .await;
11239
11240 assert_eq!(response["error"], Value::Null, "{response:#?}");
11241 let methods = response["result"]["methods"]
11242 .as_array()
11243 .expect("capabilities methods should be an array");
11244 for method in crate::rpc::MOBPACK_AUTHORING_METHODS {
11245 assert!(
11246 !methods.iter().any(|candidate| candidate == method),
11247 "console aggregator capabilities must not advertise flow-editor method {method}: {methods:#?}"
11248 );
11249 }
11250
11251 let mobpack_response = Box::pin(handle_console_aggregator_rpc(
11252 None,
11253 rpc_request("mobkit/mobpacks/schema"),
11254 true,
11255 false,
11256 None,
11257 None,
11258 ))
11259 .await;
11260 assert_eq!(
11261 mobpack_response["error"]["code"],
11262 json!(-32601),
11263 "console aggregator RPC must not handle flow-editor authoring methods: {mobpack_response:#?}"
11264 );
11265 }
11266
11267 #[tokio::test]
11268 async fn console_runtime_identity_reads_reject_stale_runtime_aliases()
11269 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11270 let (_temp_dir, runtime) =
11271 build_empty_console_test_runtime("console-identity-stale-read-alias").await?;
11272 let mut labels = BTreeMap::new();
11273 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
11274 runtime
11275 .handle()
11276 .spawn_spec(
11277 SpawnMemberSpec::from_wire(
11278 "worker".to_string(),
11279 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
11282 Some("You are the stale Review Agent.".into()),
11283 None,
11284 None,
11285 )
11286 .with_labels(labels),
11287 )
11288 .await?;
11289
11290 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11291 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11292 lease_provider: Arc::new(LocalLeaseProvider::new()),
11293 runtime_instance_id: "console-identity-stale-read-alias".to_string(),
11294 has_runtime_store: true,
11295 durability_policy: DurabilityPolicy::SyncWriteThrough,
11296 bridge: None,
11297 default_timeout: None,
11298 }));
11299 let identity = AgentIdentity::parse("review:singleton")?;
11300 let record = ContinuityRecord {
11301 identity: identity.clone(),
11302 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:1")?,
11303 session_id: meerkat_core::types::SessionId::new(),
11304 generation: ContinuityGeneration::new(1),
11305 checkpoint_version: CheckpointVersion::new(0),
11306 };
11307 identity_runtime
11308 .register(
11309 DurableAgentSpec {
11310 identity: identity.clone(),
11311 profile: ProfileName::from("worker"),
11312 addressability: AgentAddressability::Addressable,
11313 display_name: None,
11314 labels: BTreeMap::new(),
11315 context: None,
11316 additional_instructions: Vec::new(),
11317 initial_message: None,
11318 runtime_mode_override: None,
11319 backend: None,
11320 binding: None,
11321 },
11322 IdentityLifecycleState::Active,
11323 Some(record),
11324 Some(LeaseGrant {
11325 identity,
11326 fencing_token: FencingToken::new(7),
11327 ttl: Duration::from_mins(1),
11328 }),
11329 )
11330 .await;
11331
11332 for requested_identity in ["rt:review:singleton:0", "review:singleton"] {
11333 for method in ["mobkit/status_identity", "mobkit/inspect_identity"] {
11334 let response = Box::pin(handle_console_runtime_rpc(
11335 &runtime,
11336 None,
11337 None,
11338 None,
11339 None,
11340 None,
11341 Some(identity_runtime.clone()),
11342 None,
11343 None,
11344 rpc_request_with_params(method, json!({ "identity": requested_identity })),
11345 true,
11346 ))
11347 .await;
11348 assert_ne!(
11349 response["error"],
11350 Value::Null,
11351 "{method} must reject stale alias for {requested_identity}"
11352 );
11353 let message = response["error"]["message"].as_str().unwrap_or_default();
11354 assert!(
11355 message.contains(
11356 "identity runtime binding for review:singleton points at rt:review:singleton:1"
11357 ),
11358 "unexpected stale-alias message for {method}/{requested_identity}: {message}"
11359 );
11360 assert_eq!(
11361 response["error"]["data"]["kind"],
11362 json!("stale_identity_runtime_binding")
11363 );
11364 assert_eq!(
11365 response["error"]["data"]["registered_runtime_member_id"],
11366 json!("rt:review:singleton:1")
11367 );
11368 assert_eq!(
11369 response["error"]["data"]["live_runtime_member_id"],
11370 json!("rt:review:singleton:0")
11371 );
11372 }
11373 }
11374
11375 let list_response = Box::pin(handle_console_runtime_rpc(
11376 &runtime,
11377 None,
11378 None,
11379 None,
11380 None,
11381 None,
11382 Some(identity_runtime.clone()),
11383 None,
11384 None,
11385 rpc_request("mobkit/list_members"),
11386 true,
11387 ))
11388 .await;
11389 let listed = list_response["result"].as_array().expect("list members");
11390 assert!(
11391 listed
11392 .iter()
11393 .all(|entry| entry["agent_identity"] != json!("rt:review:singleton:0")),
11394 "list_members must filter stale runtime aliases: {list_response:#?}"
11395 );
11396
11397 let find_response = Box::pin(handle_console_runtime_rpc(
11398 &runtime,
11399 None,
11400 None,
11401 None,
11402 None,
11403 None,
11404 Some(identity_runtime.clone()),
11405 None,
11406 None,
11407 rpc_request_with_params(
11408 "mobkit/find_members",
11409 json!({ "label_key": "agent_identity", "label_value": "review:singleton" }),
11410 ),
11411 true,
11412 ))
11413 .await;
11414 let found = find_response["result"].as_array().expect("find members");
11415 assert!(
11416 found
11417 .iter()
11418 .all(|entry| entry["agent_identity"] != json!("rt:review:singleton:0")),
11419 "find_members must filter stale runtime aliases: {find_response:#?}"
11420 );
11421
11422 for method in [
11423 "mobkit/get_member",
11424 "mobkit/member_status",
11425 "mobkit/identity/resolved_tools",
11426 "mobkit/retire_member",
11427 "mobkit/respawn_member",
11428 "mobkit/force_cancel_member",
11429 ] {
11430 let param_name = if method == "mobkit/identity/resolved_tools" {
11431 "identity"
11432 } else {
11433 "member_id"
11434 };
11435 let response = Box::pin(handle_console_runtime_rpc(
11436 &runtime,
11437 None,
11438 None,
11439 None,
11440 None,
11441 None,
11442 Some(identity_runtime.clone()),
11443 None,
11444 None,
11445 rpc_request_with_params(method, json!({ param_name: "rt:review:singleton:0" })),
11446 true,
11447 ))
11448 .await;
11449 assert_ne!(
11450 response["error"],
11451 Value::Null,
11452 "{method} must reject stale runtime alias"
11453 );
11454 assert_eq!(
11455 response["error"]["data"]["kind"],
11456 json!("stale_identity_runtime_binding")
11457 );
11458 assert_eq!(
11459 response["error"]["data"]["live_runtime_member_id"],
11460 json!("rt:review:singleton:0")
11461 );
11462 }
11463
11464 let (_temp_dir_without_stale, runtime_without_stale) =
11465 build_empty_console_test_runtime("console-identity-no-live-stale-alias").await?;
11466
11467 for method in [
11468 "mobkit/status_identity",
11469 "mobkit/inspect_identity",
11470 "mobkit/retire",
11471 "mobkit/respawn",
11472 "mobkit/reset",
11473 ] {
11474 let response = Box::pin(handle_console_runtime_rpc(
11475 &runtime_without_stale,
11476 None,
11477 None,
11478 None,
11479 Some(ConsoleEventStore::new()),
11480 None,
11481 Some(identity_runtime.clone()),
11482 None,
11483 None,
11484 rpc_request_with_params(method, json!({ "identity": "rt:review:singleton:0" })),
11485 true,
11486 ))
11487 .await;
11488 assert_ne!(
11489 response["error"],
11490 Value::Null,
11491 "{method} must reject stale synthetic runtime alias"
11492 );
11493 assert!(
11494 response["error"]["message"]
11495 .as_str()
11496 .unwrap_or_default()
11497 .contains("identity not found: rt:review:singleton:0"),
11498 "unexpected no-live stale-alias response for {method}: {response:#?}"
11499 );
11500 }
11501 let _ = runtime_without_stale.handle().stop().await;
11502
11503 let _ = runtime.handle().stop().await;
11504 Ok(())
11505 }
11506
11507 #[tokio::test]
11508 async fn aggregator_live_snapshot_projects_identity_first_topology_peers()
11509 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11510 let (_temp_dir, mob_runtime) =
11511 build_empty_console_test_runtime("identity-topology-snapshot-test").await?;
11512 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11513 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11514 lease_provider: Arc::new(LocalLeaseProvider::new()),
11515 runtime_instance_id: "console-topology-snapshot-test".to_string(),
11516 has_runtime_store: true,
11517 durability_policy: DurabilityPolicy::SyncWriteThrough,
11518 bridge: None,
11519 default_timeout: None,
11520 }));
11521
11522 for name in ["agent:alpha", "agent:beta"] {
11523 let identity = AgentIdentity::parse(name)?;
11524 let record = ContinuityRecord {
11525 identity: identity.clone(),
11526 agent_runtime_id: AgentRuntimeId::parse(&format!("rt:{name}:0"))?,
11527 session_id: meerkat_core::types::SessionId::new(),
11528 generation: ContinuityGeneration::new(0),
11529 checkpoint_version: CheckpointVersion::new(0),
11530 };
11531 identity_runtime
11532 .register(
11533 DurableAgentSpec {
11534 identity: identity.clone(),
11535 profile: ProfileName::from("default"),
11536 addressability: AgentAddressability::Addressable,
11537 display_name: None,
11538 labels: BTreeMap::new(),
11539 context: None,
11540 additional_instructions: Vec::new(),
11541 initial_message: None,
11542 runtime_mode_override: None,
11543 backend: None,
11544 binding: None,
11545 },
11546 IdentityLifecycleState::Active,
11547 Some(record),
11548 Some(LeaseGrant {
11549 identity,
11550 fencing_token: FencingToken::new(7),
11551 ttl: Duration::from_mins(1),
11552 }),
11553 )
11554 .await;
11555 }
11556 identity_runtime
11557 .set_desired_peer_edges(vec![ManagedPeerEdge::new(
11558 AgentIdentity::parse("agent:alpha")?,
11559 AgentIdentity::parse("agent:beta")?,
11560 )?])
11561 .await;
11562
11563 let aggregator = MobKitConsoleAggregator::in_memory();
11564 aggregator.register_runtime_handles_with_policy(
11565 "identity-first",
11566 "",
11567 mob_runtime.clone(),
11568 Some(identity_runtime),
11569 ConsoleEventStore::new(),
11570 Arc::new(AllowAllConsoleVisibilityPolicy),
11571 );
11572
11573 let snapshot = build_aggregator_live_snapshot(&aggregator, &[]).await?;
11574 let alpha = snapshot
11575 .members
11576 .iter()
11577 .find(|member| member.agent_identity == "agent:alpha")
11578 .ok_or("agent:alpha missing from live snapshot")?;
11579 assert_eq!(alpha.wired_to, vec!["agent:beta".to_string()]);
11580
11581 let _ = mob_runtime.handle().stop().await;
11582 Ok(())
11583 }
11584
11585 #[tokio::test]
11586 async fn identity_first_console_send_reserves_timeline_and_uses_identity_runtime()
11587 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11588 let (_temp_dir, mob_runtime) =
11589 build_empty_console_test_runtime("identity-send-runtime-key-test").await?;
11590 let identity = AgentIdentity::parse("agent:console")?;
11591 let record = ContinuityRecord {
11592 identity: identity.clone(),
11593 agent_runtime_id: AgentRuntimeId::parse("rt:agent:console:0")?,
11594 session_id: meerkat_core::types::SessionId::new(),
11595 generation: ContinuityGeneration::new(0),
11596 checkpoint_version: CheckpointVersion::new(0),
11597 };
11598 let runtime = IdentityRuntime::new(IdentityRuntimeConfig {
11599 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11600 lease_provider: Arc::new(LocalLeaseProvider::new()),
11601 runtime_instance_id: "console-test".to_string(),
11602 has_runtime_store: true,
11603 durability_policy: DurabilityPolicy::SyncWriteThrough,
11604 bridge: None,
11605 default_timeout: None,
11606 });
11607 runtime
11608 .register(
11609 DurableAgentSpec {
11610 identity: identity.clone(),
11611 profile: ProfileName::from("default"),
11612 addressability: AgentAddressability::Addressable,
11613 display_name: None,
11614 labels: BTreeMap::new(),
11615 context: None,
11616 additional_instructions: Vec::new(),
11617 initial_message: None,
11618 runtime_mode_override: None,
11619 backend: None,
11620 binding: None,
11621 },
11622 IdentityLifecycleState::Active,
11623 Some(record.clone()),
11624 Some(LeaseGrant {
11625 identity: identity.clone(),
11626 fencing_token: FencingToken::new(7),
11627 ttl: Duration::from_mins(1),
11628 }),
11629 )
11630 .await;
11631
11632 let aggregator = MobKitConsoleAggregator::in_memory();
11633 let events = ConsoleEventStore::new();
11634 let runtime = Arc::new(runtime);
11635 aggregator.register_runtime_handles_with_policy(
11636 "default",
11637 "",
11638 mob_runtime.clone(),
11639 Some(runtime.clone()),
11640 events.clone(),
11641 Arc::new(AllowAllConsoleVisibilityPolicy),
11642 );
11643 let accepted = console_send_identity_first(
11644 &aggregator,
11645 runtime.clone(),
11646 Some(&mob_runtime),
11647 Some(&events),
11648 crate::console_aggregator::ConsoleSendRequest {
11649 identity: identity.as_str().to_string(),
11650 content: serde_json::to_value(meerkat_core::ContentInput::Text(
11651 "hello".to_string(),
11652 ))?,
11653 origin: "test".to_string(),
11654 idempotency_key: "idem-1".to_string(),
11655 handling_mode: None,
11656 },
11657 )
11658 .await?;
11659
11660 assert_eq!(accepted.identity, identity.as_str());
11661 assert_eq!(accepted.status, ConsoleFrameStatus::Accepted);
11662 assert_eq!(accepted.session_id, Some(record.session_id.to_string()));
11663
11664 let page = aggregator
11665 .query_timeline(ConsoleTimelineQuery {
11666 identity: Some(identity.as_str().to_string()),
11667 ..ConsoleTimelineQuery::default()
11668 })
11669 .await?;
11670 assert_eq!(page.frames.len(), 1);
11671 assert_eq!(page.frames[0].runtime_key, "default");
11672 assert_eq!(page.frames[0].status, ConsoleFrameStatus::Accepted);
11673 assert_eq!(
11674 page.frames[0].session_id,
11675 Some(record.session_id.to_string())
11676 );
11677 let _ = mob_runtime.handle().stop().await;
11678 Ok(())
11679 }
11680
11681 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
11682 async fn identity_first_console_send_falls_back_to_member_only_spawned_worker()
11683 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11684 let (_temp_dir, mob_runtime) =
11685 build_empty_console_test_runtime("identity-send-member-only-test").await?;
11686 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11687 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11688 lease_provider: Arc::new(LocalLeaseProvider::new()),
11689 runtime_instance_id: "console-member-only-send-test".to_string(),
11690 has_runtime_store: true,
11691 durability_policy: DurabilityPolicy::SyncWriteThrough,
11692 bridge: None,
11693 default_timeout: None,
11694 }));
11695
11696 let aggregator = MobKitConsoleAggregator::in_memory();
11697 let events = ConsoleEventStore::new();
11698 aggregator.register_runtime_handles_with_policy(
11699 "identity-first",
11700 "",
11701 mob_runtime.clone(),
11702 Some(identity_runtime.clone()),
11703 events.clone(),
11704 Arc::new(AllowAllConsoleVisibilityPolicy),
11705 );
11706
11707 mob_runtime
11708 .handle()
11709 .spawn_spec(SpawnMemberSpec::from_wire(
11710 "worker".to_string(),
11711 crate::member_comms_id::mob_member_id_str("agent:member-only").into_owned(),
11714 Some("You are a member-only spawned worker.".into()),
11715 None,
11716 None,
11717 ))
11718 .await?;
11719
11720 let accepted = console_send_with_identity_first_fallback(
11721 &aggregator,
11722 identity_runtime,
11723 Some(&mob_runtime),
11724 Some(&events),
11725 crate::console_aggregator::ConsoleSendRequest {
11726 identity: "agent:member-only".to_string(),
11727 content: serde_json::to_value(meerkat_core::ContentInput::Text(
11728 "hello spawned worker".to_string(),
11729 ))?,
11730 origin: "test".to_string(),
11731 idempotency_key: "member-only-idem-1".to_string(),
11732 handling_mode: None,
11733 },
11734 )
11735 .await?;
11736
11737 assert_eq!(accepted.identity, "agent:member-only");
11738 assert!(accepted.session_id.is_some());
11739
11740 let page = aggregator
11741 .query_timeline(ConsoleTimelineQuery {
11742 identity: Some("agent:member-only".to_string()),
11743 ..ConsoleTimelineQuery::default()
11744 })
11745 .await?;
11746 assert!(
11747 page.frames.iter().any(|frame| frame.kind == "user_input"),
11748 "fallback send should persist a user input frame for the member-only worker: {page:#?}"
11749 );
11750
11751 let _ = mob_runtime.handle().stop().await;
11752 Ok(())
11753 }
11754
11755 #[tokio::test]
11756 async fn identity_first_console_send_returns_before_bridge_delivery_completes()
11757 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11758 let identity = AgentIdentity::parse("agent:slow-console")?;
11759 let record = ContinuityRecord {
11760 identity: identity.clone(),
11761 agent_runtime_id: AgentRuntimeId::parse("rt:agent:slow-console:0")?,
11762 session_id: meerkat_core::types::SessionId::new(),
11763 generation: ContinuityGeneration::new(0),
11764 checkpoint_version: CheckpointVersion::new(0),
11765 };
11766 let deliver_calls = Arc::new(AtomicUsize::new(0));
11767 let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11768 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11769 lease_provider: Arc::new(LocalLeaseProvider::new()),
11770 runtime_instance_id: "console-slow-send-test".to_string(),
11771 has_runtime_store: true,
11772 durability_policy: DurabilityPolicy::SyncWriteThrough,
11773 bridge: Some(Arc::new(BlockingIdentityBridge {
11774 deliver_calls: deliver_calls.clone(),
11775 })),
11776 default_timeout: None,
11777 }));
11778 runtime
11779 .register(
11780 DurableAgentSpec {
11781 identity: identity.clone(),
11782 profile: ProfileName::from("default"),
11783 addressability: AgentAddressability::Addressable,
11784 display_name: None,
11785 labels: BTreeMap::new(),
11786 context: None,
11787 additional_instructions: Vec::new(),
11788 initial_message: None,
11789 runtime_mode_override: None,
11790 backend: None,
11791 binding: None,
11792 },
11793 IdentityLifecycleState::Active,
11794 Some(record.clone()),
11795 Some(LeaseGrant {
11796 identity: identity.clone(),
11797 fencing_token: FencingToken::new(7),
11798 ttl: Duration::from_mins(1),
11799 }),
11800 )
11801 .await;
11802
11803 let aggregator = MobKitConsoleAggregator::in_memory();
11804 let accepted = match tokio::time::timeout(
11805 Duration::from_millis(100),
11806 console_send_identity_first(
11807 &aggregator,
11808 runtime,
11809 None,
11810 None,
11811 crate::console_aggregator::ConsoleSendRequest {
11812 identity: identity.as_str().to_string(),
11813 content: serde_json::to_value(meerkat_core::ContentInput::Text(
11814 "hello slow bridge".to_string(),
11815 ))?,
11816 origin: "test".to_string(),
11817 idempotency_key: "idem-slow-bridge".to_string(),
11818 handling_mode: None,
11819 },
11820 ),
11821 )
11822 .await
11823 {
11824 Ok(Ok(accepted)) => accepted,
11825 Ok(Err(err)) => return Err(format!("send should be accepted: {err}").into()),
11826 Err(err) => {
11827 return Err(
11828 format!("console send should not wait for bridge delivery: {err}").into(),
11829 );
11830 }
11831 };
11832
11833 assert_eq!(accepted.status, ConsoleFrameStatus::Accepted);
11834 if tokio::time::timeout(Duration::from_millis(100), async {
11835 while deliver_calls.load(Ordering::SeqCst) == 0 {
11836 tokio::time::sleep(Duration::from_millis(5)).await;
11837 }
11838 })
11839 .await
11840 .is_err()
11841 {
11842 return Err("delivery should be spawned in the background".into());
11843 }
11844 Ok(())
11845 }
11846
11847 #[tokio::test]
11848 async fn identity_first_console_steer_waits_for_bridge_delivery()
11849 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11850 let identity = AgentIdentity::parse("agent:slow-steer-console")?;
11851 let record = ContinuityRecord {
11852 identity: identity.clone(),
11853 agent_runtime_id: AgentRuntimeId::parse("rt:agent:slow-steer-console:0")?,
11854 session_id: meerkat_core::types::SessionId::new(),
11855 generation: ContinuityGeneration::new(0),
11856 checkpoint_version: CheckpointVersion::new(0),
11857 };
11858 let deliver_calls = Arc::new(AtomicUsize::new(0));
11859 let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11860 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11861 lease_provider: Arc::new(LocalLeaseProvider::new()),
11862 runtime_instance_id: "console-slow-steer-send-test".to_string(),
11863 has_runtime_store: true,
11864 durability_policy: DurabilityPolicy::SyncWriteThrough,
11865 bridge: Some(Arc::new(BlockingIdentityBridge {
11866 deliver_calls: deliver_calls.clone(),
11867 })),
11868 default_timeout: None,
11869 }));
11870 runtime
11871 .register(
11872 DurableAgentSpec {
11873 identity: identity.clone(),
11874 profile: ProfileName::from("default"),
11875 addressability: AgentAddressability::Addressable,
11876 display_name: None,
11877 labels: BTreeMap::new(),
11878 context: None,
11879 additional_instructions: Vec::new(),
11880 initial_message: None,
11881 runtime_mode_override: None,
11882 backend: None,
11883 binding: None,
11884 },
11885 IdentityLifecycleState::Active,
11886 Some(record),
11887 Some(LeaseGrant {
11888 identity: identity.clone(),
11889 fencing_token: FencingToken::new(7),
11890 ttl: Duration::from_mins(1),
11891 }),
11892 )
11893 .await;
11894
11895 let aggregator = MobKitConsoleAggregator::in_memory();
11896 let result = tokio::time::timeout(
11897 Duration::from_millis(100),
11898 console_send_identity_first(
11899 &aggregator,
11900 runtime,
11901 None,
11902 None,
11903 crate::console_aggregator::ConsoleSendRequest {
11904 identity: identity.as_str().to_string(),
11905 content: serde_json::to_value(meerkat_core::ContentInput::Text(
11906 "hello slow steer bridge".to_string(),
11907 ))?,
11908 origin: "test".to_string(),
11909 idempotency_key: "idem-slow-steer-bridge".to_string(),
11910 handling_mode: Some("steer".to_string()),
11911 },
11912 ),
11913 )
11914 .await;
11915
11916 if result.is_ok() {
11917 return Err("steer send must wait for bridge delivery admission".into());
11918 }
11919 assert_eq!(
11920 deliver_calls.load(Ordering::SeqCst),
11921 1,
11922 "steer delivery should have reached the bridge before the console response waits"
11923 );
11924 Ok(())
11925 }
11926
11927 #[tokio::test]
11928 async fn identity_first_console_send_forwards_handling_mode()
11929 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11930 let identity = AgentIdentity::parse("agent:mode-console")?;
11931 let record = ContinuityRecord {
11932 identity: identity.clone(),
11933 agent_runtime_id: AgentRuntimeId::parse("rt:agent:mode-console:0")?,
11934 session_id: meerkat_core::types::SessionId::new(),
11935 generation: ContinuityGeneration::new(0),
11936 checkpoint_version: CheckpointVersion::new(0),
11937 };
11938 let handling_modes = Arc::new(Mutex::new(Vec::new()));
11939 let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11940 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11941 lease_provider: Arc::new(LocalLeaseProvider::new()),
11942 runtime_instance_id: "console-mode-send-test".to_string(),
11943 has_runtime_store: true,
11944 durability_policy: DurabilityPolicy::SyncWriteThrough,
11945 bridge: Some(Arc::new(RecordingIdentityBridge {
11946 session_id: record.session_id.clone(),
11947 handling_modes: handling_modes.clone(),
11948 })),
11949 default_timeout: None,
11950 }));
11951 runtime
11952 .register(
11953 DurableAgentSpec {
11954 identity: identity.clone(),
11955 profile: ProfileName::from("default"),
11956 addressability: AgentAddressability::Addressable,
11957 display_name: None,
11958 labels: BTreeMap::new(),
11959 context: None,
11960 additional_instructions: Vec::new(),
11961 initial_message: None,
11962 runtime_mode_override: None,
11963 backend: None,
11964 binding: None,
11965 },
11966 IdentityLifecycleState::Active,
11967 Some(record),
11968 Some(LeaseGrant {
11969 identity: identity.clone(),
11970 fencing_token: FencingToken::new(7),
11971 ttl: Duration::from_mins(1),
11972 }),
11973 )
11974 .await;
11975
11976 let aggregator = MobKitConsoleAggregator::in_memory();
11977 let accepted = console_send_identity_first(
11978 &aggregator,
11979 runtime,
11980 None,
11981 None,
11982 crate::console_aggregator::ConsoleSendRequest {
11983 identity: identity.as_str().to_string(),
11984 content: serde_json::to_value(meerkat_core::ContentInput::Text(
11985 "hello steer bridge".to_string(),
11986 ))?,
11987 origin: "test".to_string(),
11988 idempotency_key: "idem-steer-bridge".to_string(),
11989 handling_mode: Some("steer".to_string()),
11990 },
11991 )
11992 .await?;
11993
11994 if tokio::time::timeout(Duration::from_millis(100), async {
11995 loop {
11996 if handling_modes
11997 .lock()
11998 .map(|modes| modes.contains(&HandlingMode::Steer))
11999 .unwrap_or(false)
12000 {
12001 break;
12002 }
12003 tokio::time::sleep(Duration::from_millis(5)).await;
12004 }
12005 })
12006 .await
12007 .is_err()
12008 {
12009 return Err("identity-first console send should forward steer mode".into());
12010 }
12011
12012 let terminal_frame = tokio::time::timeout(Duration::from_millis(500), async {
12013 loop {
12014 let page = aggregator
12015 .query_timeline(ConsoleTimelineQuery {
12016 identity: Some(identity.as_str().to_string()),
12017 ..ConsoleTimelineQuery::default()
12018 })
12019 .await
12020 .map_err(|err| format!("query timeline: {err}"))?;
12021 if page.frames.iter().any(|frame| {
12022 frame.kind == "interaction_complete"
12023 && frame.interaction_id.as_deref() == Some(accepted.interaction_id.as_str())
12024 && frame.payload.get("reason").and_then(Value::as_str)
12025 == Some("steer_delivered")
12026 }) {
12027 return Ok::<(), String>(());
12028 }
12029 tokio::time::sleep(Duration::from_millis(5)).await;
12030 }
12031 })
12032 .await;
12033 match terminal_frame {
12034 Ok(Ok(())) => {}
12035 Ok(Err(err)) => return Err(err.into()),
12036 Err(_) => {
12037 return Err(
12038 "identity-first steer send should terminalize its console interaction".into(),
12039 );
12040 }
12041 }
12042 Ok(())
12043 }
12044
12045 #[test]
12046 fn multipart_body_limit_covers_configured_image_limit() {
12047 const _: () = assert!(MAX_MULTIPART_BODY_BYTES > MAX_MULTIPART_IMAGE_BYTES);
12048 const _: () = assert!(MAX_MULTIPART_BODY_BYTES > 2 * 1024 * 1024);
12049 }
12050
12051 #[tokio::test]
12064 async fn cold_cache_waiter_resumes_when_refresh_lock_drops()
12065 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12066 use std::sync::atomic::Ordering;
12067 use tokio::time::Duration;
12068
12069 let model = ConsoleSnapshotReadModel::default();
12070 let guard = model
12071 .refresh_lock
12072 .clone()
12073 .try_lock_owned()
12074 .map_err(|_| "refresh_lock unexpectedly contended at test start")?;
12075
12076 let model_for_waiter = model.clone();
12077 let waiter = tokio::spawn(async move {
12078 if model_for_waiter
12081 .primed
12082 .load(std::sync::atomic::Ordering::Acquire)
12083 {
12084 return;
12085 }
12086 let _wait_guard = model_for_waiter.refresh_lock.clone().lock_owned().await;
12087 assert!(
12090 model_for_waiter
12091 .primed
12092 .load(std::sync::atomic::Ordering::Acquire),
12093 "waiter acquired lock but primed is still false"
12094 );
12095 });
12096
12097 tokio::time::sleep(Duration::from_millis(20)).await;
12099
12100 model.primed.store(true, Ordering::Release);
12103 drop(guard);
12104
12105 let result = tokio::time::timeout(Duration::from_secs(1), waiter).await;
12106 assert!(
12107 result.is_ok(),
12108 "waiter should resume once the refresh lock drops"
12109 );
12110 Ok(())
12111 }
12112
12113 #[tokio::test]
12118 async fn snapshot_skips_refresh_lock_when_already_primed()
12119 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12120 use std::sync::atomic::Ordering;
12121 use tokio::time::Duration;
12122
12123 let model = ConsoleSnapshotReadModel::default();
12124 model.primed.store(true, Ordering::Release);
12125 let _guard = model
12127 .refresh_lock
12128 .clone()
12129 .try_lock_owned()
12130 .map_err(|_| "refresh_lock unexpectedly contended at test start")?;
12131
12132 let snap_fast_path = async {
12138 assert!(
12139 model.primed.load(Ordering::Acquire),
12140 "primed precondition for hot-cache path"
12141 );
12142 };
12143 let result = tokio::time::timeout(Duration::from_millis(100), snap_fast_path).await;
12144 assert!(result.is_ok(), "hot-cache snapshot path should not block");
12145 Ok(())
12146 }
12147
12148 #[tokio::test]
12149 async fn console_aggregator_reset_all_rpc_rejects_destructive_retire_all_semantics()
12150 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12151 let (_temp_dir, runtime) =
12152 build_empty_console_test_runtime("console-reset-fresh-identity-cache").await?;
12153 let aggregator = MobKitConsoleAggregator::in_memory();
12154 aggregator.register_runtime_handles_with_policy(
12155 "runtime-reset",
12156 "reset",
12157 runtime.clone(),
12158 None,
12159 ConsoleEventStore::new(),
12160 Arc::new(AllowAllConsoleVisibilityPolicy),
12161 );
12162 let primed_empty = aggregator.list_identities().await?;
12163 assert!(
12164 primed_empty.is_empty(),
12165 "test precondition: identity cache should be primed empty before late spawn"
12166 );
12167
12168 runtime
12169 .handle()
12170 .spawn_spec(SpawnMemberSpec::from_wire(
12171 "worker".to_string(),
12172 "agent-reset".to_string(),
12173 Some("You are agent-reset.".into()),
12174 None,
12175 None,
12176 ))
12177 .await?;
12178
12179 let response = Box::pin(handle_console_aggregator_rpc(
12180 Some(aggregator),
12181 rpc_request("mobkit/reset_all"),
12182 true,
12183 false,
12184 None,
12185 None,
12186 ))
12187 .await;
12188
12189 assert_eq!(response["result"], Value::Null);
12190 assert_eq!(
12191 response["error"]["data"]["kind"],
12192 json!("unsupported_reset_all_surface")
12193 );
12194 assert!(
12195 runtime
12196 .handle()
12197 .get_member(&meerkat_mob::ids::AgentIdentity::from("agent-reset"))
12198 .await
12199 .ok()
12200 .flatten()
12201 .is_some(),
12202 "aggregator reset_all must not retire live members while reporting unsupported"
12203 );
12204 let _ = runtime.handle().stop().await;
12205 Ok(())
12206 }
12207
12208 #[test]
12209 fn timeline_stream_cursor_filter_uses_numeric_console_sequence() {
12210 assert!(cursor_is_after(
12211 &ConsoleCursor::from("console:10"),
12212 &ConsoleCursor::from("console:9")
12213 ));
12214 assert!(!cursor_is_after(
12215 &ConsoleCursor::from("console:9"),
12216 &ConsoleCursor::from("console:10")
12217 ));
12218 }
12219
12220 #[test]
12221 fn console_live_snapshot_dedupes_repeated_delegate_identities() {
12222 let mut members = vec![
12223 ConsoleMember {
12224 agent_identity: "incident-commander".to_string(),
12225 role: "commander".to_string(),
12226 state: "active".to_string(),
12227 model_capabilities: Default::default(),
12228 runtime_mode: None,
12229 session_id: None,
12230 wired_to: Vec::new(),
12231 labels: BTreeMap::new(),
12232 },
12233 ConsoleMember {
12234 agent_identity: "qa-child".to_string(),
12235 role: "delegate".to_string(),
12236 state: "active".to_string(),
12237 model_capabilities: Default::default(),
12238 runtime_mode: None,
12239 session_id: Some("first".to_string()),
12240 wired_to: vec!["qa-parent".to_string()],
12241 labels: BTreeMap::from([(
12242 "delegate_host_identity".to_string(),
12243 "qa-parent".to_string(),
12244 )]),
12245 },
12246 ConsoleMember {
12247 agent_identity: "qa-child".to_string(),
12248 role: "delegate".to_string(),
12249 state: "active".to_string(),
12250 model_capabilities: Default::default(),
12251 runtime_mode: None,
12252 session_id: Some("second".to_string()),
12253 wired_to: vec!["qa-parent".to_string()],
12254 labels: BTreeMap::from([(
12255 "delegate_host_identity".to_string(),
12256 "qa-parent".to_string(),
12257 )]),
12258 },
12259 ];
12260
12261 dedupe_console_members_by_identity(&mut members);
12262
12263 assert_eq!(
12264 members
12265 .iter()
12266 .map(|member| member.agent_identity.as_str())
12267 .collect::<Vec<_>>(),
12268 vec!["incident-commander", "qa-child"]
12269 );
12270 assert_eq!(members[1].session_id.as_deref(), Some("first"));
12271 }
12272
12273 #[test]
12274 fn console_visibility_policy_hides_implicit_delegate_members_from_snapshot() {
12275 let mut snapshot = ConsoleLiveSnapshot::new(
12276 Some("runtime".to_string()),
12277 true,
12278 vec!["incident-commander".to_string(), "qa-child".to_string()],
12279 vec![
12280 ConsoleAgentLiveSnapshot {
12281 agent_id: "incident-commander".to_string(),
12282 member_id: "incident-commander".to_string(),
12283 label: "Incident Commander".to_string(),
12284 kind: "meerkat".to_string(),
12285 identity: Some("incident-commander".to_string()),
12286 role: Some("commander".to_string()),
12287 state: Some("active".to_string()),
12288 session_id: None,
12289 model_capabilities: Default::default(),
12290 response_phase: None,
12291 watched: None,
12292 alert_level: None,
12293 degraded: None,
12294 degraded_reason: None,
12295 },
12296 ConsoleAgentLiveSnapshot {
12297 agent_id: "qa-child".to_string(),
12298 member_id: "qa-child".to_string(),
12299 label: "QA Child".to_string(),
12300 kind: "meerkat".to_string(),
12301 identity: Some("qa-child".to_string()),
12302 role: Some("delegate".to_string()),
12303 state: Some("active".to_string()),
12304 session_id: Some("delegate-session".to_string()),
12305 model_capabilities: Default::default(),
12306 response_phase: None,
12307 watched: None,
12308 alert_level: None,
12309 degraded: None,
12310 degraded_reason: None,
12311 },
12312 ],
12313 vec![
12314 ConsoleMember {
12315 agent_identity: "incident-commander".to_string(),
12316 role: "commander".to_string(),
12317 state: "active".to_string(),
12318 model_capabilities: Default::default(),
12319 runtime_mode: None,
12320 session_id: None,
12321 wired_to: Vec::new(),
12322 labels: BTreeMap::new(),
12323 },
12324 ConsoleMember {
12325 agent_identity: "qa-child".to_string(),
12326 role: "delegate".to_string(),
12327 state: "active".to_string(),
12328 model_capabilities: Default::default(),
12329 runtime_mode: None,
12330 session_id: Some("delegate-session".to_string()),
12331 wired_to: vec!["qa-parent".to_string()],
12332 labels: BTreeMap::from([(
12333 "source_mob_id".to_string(),
12334 "implicit-qa-mob".to_string(),
12335 )]),
12336 },
12337 ],
12338 true,
12339 );
12340
12341 apply_console_visibility_policy(
12342 &mut snapshot,
12343 &HideImplicitDelegateMembersConsoleVisibilityPolicy,
12344 );
12345
12346 assert_eq!(
12347 snapshot
12348 .members
12349 .iter()
12350 .map(|member| member.agent_identity.as_str())
12351 .collect::<Vec<_>>(),
12352 vec!["incident-commander"]
12353 );
12354 assert_eq!(
12355 snapshot
12356 .agents
12357 .iter()
12358 .map(|agent| agent.agent_id.as_str())
12359 .collect::<Vec<_>>(),
12360 vec!["incident-commander"]
12361 );
12362 assert_eq!(snapshot.loaded_modules, vec!["incident-commander"]);
12363 }
12364
12365 #[tokio::test]
12366 async fn live_snapshot_member_projection_uses_roster_profile_capabilities()
12367 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12368 let temp_dir = tempfile::tempdir()?;
12369 let session_path = temp_dir.path().join("sessions");
12370 std::fs::create_dir_all(&session_path)?;
12371 let factory = AgentFactory::new(&session_path).comms(true);
12372 let session_service = Arc::new(build_ephemeral_service(factory, Config::default(), 16));
12373 let definition = MobDefinition::from_toml(
12374 r#"
12375[mob]
12376id = "console-snapshot-test"
12377
12378[profiles.worker]
12379model = "gpt-5.5"
12380
12381[profiles.worker.tools]
12382comms = true
12383"#,
12384 )?;
12385 let expected = model_capabilities_for_role(&definition, "worker");
12386 let runtime = MobRuntime::bootstrap(
12387 MobBootstrapSpec::new(definition, MobStorage::in_memory(), session_service)
12388 .with_options(MobBootstrapOptions {
12389 allow_ephemeral_sessions: true,
12390 notify_orchestrator_on_resume: true,
12391 default_llm_client: Some(Arc::new(TestClient::default())),
12392 }),
12393 )
12394 .await?;
12395 runtime
12396 .handle()
12397 .spawn_spec(SpawnMemberSpec::from_wire(
12398 "worker".to_string(),
12399 crate::member_comms_id::mob_member_id_str("worker:one").into_owned(),
12402 Some("You are worker one.".into()),
12403 None,
12404 None,
12405 ))
12406 .await?;
12407
12408 let empty_read_model = ConsoleSnapshotReadModelState::default();
12409 let (members, session_owner_by_id) = project_console_members_from_handle(
12410 &runtime.handle(),
12411 None,
12412 None,
12413 &empty_read_model,
12414 &BTreeMap::new(),
12415 )
12416 .await;
12417
12418 assert_eq!(members.len(), 1);
12419 assert_eq!(members[0].model_capabilities, expected);
12420 assert_eq!(members[0].session_id, None);
12421 assert!(session_owner_by_id.is_empty());
12422
12423 let refreshed_read_model = collect_console_snapshot_read_model(&runtime).await;
12424 let (members, session_owner_by_id) = project_console_members_from_handle(
12425 &runtime.handle(),
12426 None,
12427 None,
12428 &refreshed_read_model,
12429 &BTreeMap::new(),
12430 )
12431 .await;
12432 assert_eq!(
12433 members[0].session_id.as_ref(),
12434 session_owner_by_id.keys().next()
12435 );
12436
12437 assert_eq!(
12443 refreshed_read_model.primary_members.len(),
12444 members.len(),
12445 "primary_members cache should hold the same members as live projection"
12446 );
12447 assert_eq!(
12448 refreshed_read_model.primary_members[0].agent_identity,
12449 members[0].agent_identity
12450 );
12451 assert_eq!(
12452 refreshed_read_model.primary_members[0].session_id,
12453 members[0].session_id
12454 );
12455 Ok(())
12456 }
12457
12458 #[tokio::test]
12459 async fn fresh_timeline_snapshot_reads_tail_without_full_log_replay()
12460 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12461 let aggregator = MobKitConsoleAggregator::in_memory();
12462 for idx in 0..250_000 {
12463 aggregator
12464 .store()
12465 .append_if_absent(NewConsoleFrame {
12466 id: None,
12467 dedupe_key: format!("event-{idx}"),
12468 timestamp_ms: idx,
12469 runtime_key: "runtime-a".to_string(),
12470 identity: "agent-a".to_string(),
12471 conversation_id: Some("agent-a".to_string()),
12472 session_id: None,
12473 kind: "text_delta".to_string(),
12474 status: ConsoleFrameStatus::Completed,
12475 payload: json!({ "delta": idx }),
12476 source: ConsoleFrameSource {
12477 kind: ConsoleFrameSourceKind::ConsoleEvent,
12478 source_cursor: None,
12479 },
12480 source_event_id: Some(format!("event-{idx}")),
12481 interaction_id: None,
12482 turn_id: None,
12483 run_id: None,
12484 parent_frame_id: None,
12485 caused_by_frame_id: None,
12486 })
12487 .await?;
12488 }
12489
12490 let (frames, cursor) = query_timeline_snapshot(
12491 &aggregator,
12492 ConsoleTimelineWindowQuery {
12493 identity: Some("agent-a".to_string()),
12494 after: None,
12495 limit: 200,
12496 ..ConsoleTimelineWindowQuery::default()
12497 },
12498 )
12499 .await?;
12500
12501 assert!(!frames.is_empty());
12502 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(250_000));
12503 assert_eq!(
12504 frames.last().and_then(|frame| frame.cursor.seq()),
12505 Some(250_000)
12506 );
12507 Ok(())
12508 }
12509
12510 #[tokio::test]
12511 async fn fresh_timeline_snapshot_keeps_sparse_identity_frames()
12512 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12513 let aggregator = MobKitConsoleAggregator::in_memory();
12514 aggregator
12515 .store()
12516 .append_if_absent(NewConsoleFrame {
12517 id: None,
12518 dedupe_key: "sparse-event".to_string(),
12519 timestamp_ms: 1,
12520 runtime_key: "runtime-a".to_string(),
12521 identity: "sparse-agent".to_string(),
12522 conversation_id: Some("sparse-agent".to_string()),
12523 session_id: None,
12524 kind: "text_complete".to_string(),
12525 status: ConsoleFrameStatus::Completed,
12526 payload: json!({ "text": "still visible" }),
12527 source: ConsoleFrameSource {
12528 kind: ConsoleFrameSourceKind::ConsoleEvent,
12529 source_cursor: None,
12530 },
12531 source_event_id: Some("sparse-event".to_string()),
12532 interaction_id: None,
12533 turn_id: None,
12534 run_id: None,
12535 parent_frame_id: None,
12536 caused_by_frame_id: None,
12537 })
12538 .await?;
12539 for idx in 0..25_000 {
12540 aggregator
12541 .store()
12542 .append_if_absent(NewConsoleFrame {
12543 id: None,
12544 dedupe_key: format!("other-event-{idx}"),
12545 timestamp_ms: idx + 2,
12546 runtime_key: "runtime-a".to_string(),
12547 identity: "busy-agent".to_string(),
12548 conversation_id: Some("busy-agent".to_string()),
12549 session_id: None,
12550 kind: "text_delta".to_string(),
12551 status: ConsoleFrameStatus::Completed,
12552 payload: json!({ "delta": idx }),
12553 source: ConsoleFrameSource {
12554 kind: ConsoleFrameSourceKind::ConsoleEvent,
12555 source_cursor: None,
12556 },
12557 source_event_id: Some(format!("other-event-{idx}")),
12558 interaction_id: None,
12559 turn_id: None,
12560 run_id: None,
12561 parent_frame_id: None,
12562 caused_by_frame_id: None,
12563 })
12564 .await?;
12565 }
12566
12567 let (frames, cursor) = query_timeline_snapshot(
12568 &aggregator,
12569 ConsoleTimelineWindowQuery {
12570 identity: Some("sparse-agent".to_string()),
12571 after: None,
12572 limit: 200,
12573 ..ConsoleTimelineWindowQuery::default()
12574 },
12575 )
12576 .await?;
12577
12578 assert_eq!(frames.len(), 1);
12579 assert_eq!(frames[0].identity, "sparse-agent");
12580 assert_eq!(frames[0].payload["text"], json!("still visible"));
12581 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(1));
12582 Ok(())
12583 }
12584
12585 #[tokio::test]
12586 async fn fresh_identity_snapshot_keeps_user_input_anchor_before_noisy_tail()
12587 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12588 let aggregator = MobKitConsoleAggregator::in_memory();
12589 aggregator
12590 .store()
12591 .append_if_absent(NewConsoleFrame {
12592 id: None,
12593 dedupe_key: "worker-kickoff".to_string(),
12594 timestamp_ms: 1,
12595 runtime_key: "runtime-a".to_string(),
12596 identity: "review-worker-a".to_string(),
12597 conversation_id: Some("review-worker-a".to_string()),
12598 session_id: None,
12599 kind: "user_input".to_string(),
12600 status: ConsoleFrameStatus::Delivered,
12601 payload: json!({
12602 "content": [
12603 {
12604 "type": "text",
12605 "text": "Console chat smoke: review this initiative"
12606 }
12607 ]
12608 }),
12609 source: ConsoleFrameSource {
12610 kind: ConsoleFrameSourceKind::Synthetic,
12611 source_cursor: None,
12612 },
12613 source_event_id: Some("worker-kickoff".to_string()),
12614 interaction_id: Some("kickoff-1".to_string()),
12615 turn_id: None,
12616 run_id: None,
12617 parent_frame_id: None,
12618 caused_by_frame_id: None,
12619 })
12620 .await?;
12621 for idx in 0..1_500 {
12622 aggregator
12623 .store()
12624 .append_if_absent(NewConsoleFrame {
12625 id: None,
12626 dedupe_key: format!("worker-delta-{idx}"),
12627 timestamp_ms: idx + 2,
12628 runtime_key: "runtime-a".to_string(),
12629 identity: "review-worker-a".to_string(),
12630 conversation_id: Some("review-worker-a".to_string()),
12631 session_id: None,
12632 kind: "reasoning_delta".to_string(),
12633 status: ConsoleFrameStatus::Delivered,
12634 payload: json!({ "delta": idx }),
12635 source: ConsoleFrameSource {
12636 kind: ConsoleFrameSourceKind::ConsoleEvent,
12637 source_cursor: None,
12638 },
12639 source_event_id: Some(format!("worker-delta-{idx}")),
12640 interaction_id: Some("kickoff-1".to_string()),
12641 turn_id: None,
12642 run_id: None,
12643 parent_frame_id: None,
12644 caused_by_frame_id: None,
12645 })
12646 .await?;
12647 }
12648
12649 let (frames, cursor) = query_timeline_snapshot(
12650 &aggregator,
12651 ConsoleTimelineWindowQuery {
12652 identity: Some("review-worker-a".to_string()),
12653 after: None,
12654 limit: 200,
12655 ..ConsoleTimelineWindowQuery::default()
12656 },
12657 )
12658 .await?;
12659
12660 assert!(
12661 frames.iter().any(|frame| {
12662 frame.kind == "user_input"
12663 && frame.payload.to_string().contains("Console chat smoke")
12664 }),
12665 "identity chat snapshot must keep the worker kickoff prompt before a noisy tail: {frames:#?}",
12666 );
12667 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(1_501));
12668 assert_eq!(
12669 frames.last().and_then(|frame| frame.cursor.seq()),
12670 Some(1_501)
12671 );
12672 Ok(())
12673 }
12674
12675 #[tokio::test]
12676 async fn timeline_snapshot_drains_since_backlog_across_store_pages()
12677 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12678 let aggregator = MobKitConsoleAggregator::in_memory();
12679 for idx in 0..2_500 {
12680 aggregator
12681 .store()
12682 .append_if_absent(NewConsoleFrame {
12683 id: None,
12684 dedupe_key: format!("clamp-event-{idx}"),
12685 timestamp_ms: idx,
12686 runtime_key: "runtime-a".to_string(),
12687 identity: "agent-a".to_string(),
12688 conversation_id: Some("agent-a".to_string()),
12689 session_id: None,
12690 kind: "text_delta".to_string(),
12691 status: ConsoleFrameStatus::Completed,
12692 payload: json!({ "delta": idx }),
12693 source: ConsoleFrameSource {
12694 kind: ConsoleFrameSourceKind::ConsoleEvent,
12695 source_cursor: None,
12696 },
12697 source_event_id: Some(format!("clamp-event-{idx}")),
12698 interaction_id: None,
12699 turn_id: None,
12700 run_id: None,
12701 parent_frame_id: None,
12702 caused_by_frame_id: None,
12703 })
12704 .await?;
12705 }
12706
12707 let (frames, cursor) = query_timeline_snapshot(
12708 &aggregator,
12709 ConsoleTimelineWindowQuery {
12710 identity: Some("agent-a".to_string()),
12711 after: Some(ConsoleCursor::from("console:100")),
12712 limit: 5_000,
12713 ..ConsoleTimelineWindowQuery::default()
12714 },
12715 )
12716 .await?;
12717
12718 assert_eq!(frames.len(), 2_400);
12719 assert_eq!(
12720 frames.first().and_then(|frame| frame.cursor.seq()),
12721 Some(101)
12722 );
12723 assert_eq!(
12724 frames.last().and_then(|frame| frame.cursor.seq()),
12725 Some(2_500)
12726 );
12727 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(2_500));
12728 Ok(())
12729 }
12730
12731 #[tokio::test]
12732 async fn timeline_snapshot_drains_since_backlog_beyond_old_page_budget()
12733 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12734 let aggregator = MobKitConsoleAggregator::in_memory();
12735 for idx in 1..=150 {
12736 aggregator
12737 .store()
12738 .append_if_absent(NewConsoleFrame {
12739 id: None,
12740 dedupe_key: format!("deep-backlog-event-{idx}"),
12741 timestamp_ms: idx,
12742 runtime_key: "runtime-a".to_string(),
12743 identity: "agent-a".to_string(),
12744 conversation_id: Some("agent-a".to_string()),
12745 session_id: None,
12746 kind: "text_delta".to_string(),
12747 status: ConsoleFrameStatus::Completed,
12748 payload: json!({ "delta": idx }),
12749 source: ConsoleFrameSource {
12750 kind: ConsoleFrameSourceKind::ConsoleEvent,
12751 source_cursor: None,
12752 },
12753 source_event_id: Some(format!("deep-backlog-event-{idx}")),
12754 interaction_id: None,
12755 turn_id: None,
12756 run_id: None,
12757 parent_frame_id: None,
12758 caused_by_frame_id: None,
12759 })
12760 .await?;
12761 }
12762
12763 let (frames, cursor) = query_timeline_snapshot(
12764 &aggregator,
12765 ConsoleTimelineWindowQuery {
12766 identity: Some("agent-a".to_string()),
12767 after: Some(ConsoleCursor::from_seq(1)),
12768 limit: 1,
12769 ..ConsoleTimelineWindowQuery::default()
12770 },
12771 )
12772 .await?;
12773
12774 assert_eq!(frames.len(), 149);
12775 assert_eq!(frames.first().and_then(|frame| frame.cursor.seq()), Some(2));
12776 assert_eq!(
12777 frames.last().and_then(|frame| frame.cursor.seq()),
12778 Some(150)
12779 );
12780 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(150));
12781 Ok(())
12782 }
12783
12784 #[tokio::test]
12785 async fn timeline_snapshot_rejects_after_cursor_beyond_store_frontier()
12786 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12787 let aggregator = MobKitConsoleAggregator::in_memory();
12788 aggregator
12789 .store()
12790 .append_if_absent(NewConsoleFrame {
12791 id: None,
12792 dedupe_key: "stale-frontier-event".to_string(),
12793 timestamp_ms: 1,
12794 runtime_key: "runtime-a".to_string(),
12795 identity: "agent-a".to_string(),
12796 conversation_id: Some("agent-a".to_string()),
12797 session_id: None,
12798 kind: "text_delta".to_string(),
12799 status: ConsoleFrameStatus::Completed,
12800 payload: json!({ "delta": 1 }),
12801 source: ConsoleFrameSource {
12802 kind: ConsoleFrameSourceKind::ConsoleEvent,
12803 source_cursor: None,
12804 },
12805 source_event_id: Some("stale-frontier-event".to_string()),
12806 interaction_id: None,
12807 turn_id: None,
12808 run_id: None,
12809 parent_frame_id: None,
12810 caused_by_frame_id: None,
12811 })
12812 .await?;
12813
12814 let err = match query_timeline_snapshot(
12815 &aggregator,
12816 ConsoleTimelineWindowQuery {
12817 after: Some(ConsoleCursor::from("console:99")),
12818 limit: 200,
12819 ..ConsoleTimelineWindowQuery::default()
12820 },
12821 )
12822 .await
12823 {
12824 Ok(_) => {
12825 return Err(
12826 std::io::Error::other("future cursor must be replay-unavailable").into(),
12827 );
12828 }
12829 Err(err) => err,
12830 };
12831
12832 assert!(
12833 err.to_string()
12834 .contains("beyond the current store frontier"),
12835 "unexpected error: {err}"
12836 );
12837 Ok(())
12838 }
12839
12840 #[tokio::test]
12841 async fn timeline_snapshot_rejects_after_cursor_beyond_empty_store_frontier()
12842 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12843 let aggregator = MobKitConsoleAggregator::in_memory();
12844
12845 let err = match query_timeline_snapshot(
12846 &aggregator,
12847 ConsoleTimelineWindowQuery {
12848 after: Some(ConsoleCursor::from("console:99")),
12849 limit: 200,
12850 ..ConsoleTimelineWindowQuery::default()
12851 },
12852 )
12853 .await
12854 {
12855 Ok(_) => {
12856 return Err(std::io::Error::other("empty store future cursor must fail").into());
12857 }
12858 Err(err) => err,
12859 };
12860
12861 assert!(
12862 err.to_string()
12863 .contains("beyond the current store frontier"),
12864 "unexpected error: {err}"
12865 );
12866 Ok(())
12867 }
12868
12869 #[test]
12870 fn timeline_query_prefers_last_event_id_over_url_after_cursor() {
12871 let query = timeline_query_from_http(
12872 ConsoleTimelineHttpQuery {
12873 identity: None,
12874 conversation_id: None,
12875 after: Some("console:100".to_string()),
12876 before: None,
12877 mode: None,
12878 limit: None,
12879 },
12880 Some("console:150".to_string()),
12881 );
12882
12883 assert_eq!(query.after.as_ref().and_then(ConsoleCursor::seq), Some(150));
12884 }
12885
12886 #[test]
12887 fn console_timeline_replay_unavailable_rpc_uses_dedicated_error_code() {
12888 let response = console_timeline_replay_unavailable_response(
12889 json!("rid"),
12890 std::io::Error::other("timeline replay cursor is beyond the current store frontier")
12891 .into(),
12892 Some(&ConsoleCursor::from_seq(100)),
12893 Some(ConsoleCursor::from_seq(42)),
12894 );
12895
12896 assert_eq!(response["error"]["code"], json!(-32013));
12897 assert_eq!(
12898 response["error"]["data"],
12899 json!({
12900 "error": "replay_unavailable",
12901 "stream": "timeline",
12902 "requested_cursor": "console:100",
12903 "latest_cursor": "console:42",
12904 })
12905 );
12906 }
12907
12908 #[tokio::test]
12909 async fn multipart_blob_upload_stores_one_file() -> Result<(), Box<dyn std::error::Error>> {
12910 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
12911 let mut files = BTreeMap::new();
12912 files.insert(
12913 "upload-1".to_string(),
12914 MultipartImageUpload {
12915 media_type: "image/png".to_string(),
12916 bytes: Bytes::from_static(b"png-data"),
12917 },
12918 );
12919 let result = externalize_single_image_upload(
12920 &json!({
12921 "upload": {
12922 "type": "image_upload",
12923 "upload_id": "upload-1",
12924 "media_type": "image/png"
12925 }
12926 }),
12927 files,
12928 store.clone(),
12929 )
12930 .await
12931 .map_err(std::io::Error::other)?;
12932
12933 assert_eq!(result["media_type"], json!("image/png"));
12934 assert_eq!(result["size"], json!(8));
12935 let Some(blob_id) = result["blob_id"].as_str() else {
12936 return Err(std::io::Error::other("blob id").into());
12937 };
12938 let payload = store
12939 .get_bytes(&meerkat_core::BlobId::from(blob_id))
12940 .await?;
12941 assert_eq!(payload.data.as_ref(), b"png-data");
12942 Ok(())
12943 }
12944
12945 #[tokio::test]
12946 async fn multipart_blob_upload_accepts_part_name_alias()
12947 -> Result<(), Box<dyn std::error::Error>> {
12948 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
12949 let mut files = BTreeMap::new();
12950 files.insert(
12951 "image-field".to_string(),
12952 MultipartImageUpload {
12953 media_type: "image/png".to_string(),
12954 bytes: Bytes::from_static(b"png-data"),
12955 },
12956 );
12957 let result = externalize_single_image_upload(
12958 &json!({
12959 "upload": {
12960 "type": "image_upload",
12961 "part_name": "image-field",
12962 "media_type": "image/png"
12963 }
12964 }),
12965 files,
12966 store,
12967 )
12968 .await
12969 .map_err(std::io::Error::other)?;
12970
12971 assert_eq!(result["media_type"], json!("image/png"));
12972 assert!(
12973 result["blob_id"]
12974 .as_str()
12975 .is_some_and(|value| value.starts_with("sha256:"))
12976 );
12977 Ok(())
12978 }
12979
12980 #[tokio::test]
12981 async fn multipart_blob_upload_rejects_media_mismatch() -> Result<(), Box<dyn std::error::Error>>
12982 {
12983 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
12984 let mut files = BTreeMap::new();
12985 files.insert(
12986 "upload-1".to_string(),
12987 MultipartImageUpload {
12988 media_type: "image/jpeg".to_string(),
12989 bytes: Bytes::from_static(b"jpeg-data"),
12990 },
12991 );
12992 let err = match externalize_single_image_upload(
12993 &json!({
12994 "upload": {
12995 "type": "image_upload",
12996 "upload_id": "upload-1",
12997 "media_type": "image/png"
12998 }
12999 }),
13000 files,
13001 store,
13002 )
13003 .await
13004 {
13005 Ok(_) => return Err(std::io::Error::other("media mismatch").into()),
13006 Err(err) => err,
13007 };
13008 assert!(
13009 err.contains("media type mismatch"),
13010 "unexpected error: {err}"
13011 );
13012 Ok(())
13013 }
13014
13015 #[tokio::test]
13016 async fn multipart_blob_upload_rejects_extra_file() -> Result<(), Box<dyn std::error::Error>> {
13017 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13018 let mut files = BTreeMap::new();
13019 for id in ["upload-1", "upload-2"] {
13020 files.insert(
13021 id.to_string(),
13022 MultipartImageUpload {
13023 media_type: "image/png".to_string(),
13024 bytes: Bytes::from_static(b"png"),
13025 },
13026 );
13027 }
13028 let err = match externalize_single_image_upload(
13029 &json!({
13030 "upload": {
13031 "type": "image_upload",
13032 "upload_id": "upload-1",
13033 "media_type": "image/png"
13034 }
13035 }),
13036 files,
13037 store,
13038 )
13039 .await
13040 {
13041 Ok(_) => return Err(std::io::Error::other("one file only").into()),
13042 Err(err) => err,
13043 };
13044 assert!(
13045 err.contains("exactly one file part"),
13046 "unexpected error: {err}"
13047 );
13048 Ok(())
13049 }
13050
13051 #[tokio::test]
13052 async fn multipart_send_replaces_placeholders_and_removes_shadow_message()
13053 -> Result<(), Box<dyn std::error::Error>> {
13054 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13055 let mut files = BTreeMap::new();
13056 files.insert(
13057 "upload-1".to_string(),
13058 MultipartImageUpload {
13059 media_type: "image/webp".to_string(),
13060 bytes: Bytes::from_static(b"webp-data"),
13061 },
13062 );
13063 let mut params = json!({
13064 "member_id": "artist",
13065 "message": "stale shadow text",
13066 "content": [
13067 { "type": "text", "text": "describe" },
13068 {
13069 "type": "image_upload",
13070 "upload_id": "upload-1",
13071 "media_type": "image/webp"
13072 }
13073 ]
13074 });
13075 externalize_image_upload_placeholders(&mut params, files, store)
13076 .await
13077 .map_err(std::io::Error::other)?;
13078
13079 assert!(params.get("message").is_none());
13080 assert_eq!(params["content"][1]["type"], json!("image"));
13081 assert_eq!(params["content"][1]["source"], json!("blob"));
13082 assert_eq!(params["content"][1]["media_type"], json!("image/webp"));
13083 assert!(
13084 params["content"][1]["blob_id"]
13085 .as_str()
13086 .is_some_and(|value| value.starts_with("sha256:"))
13087 );
13088 Ok(())
13089 }
13090
13091 #[tokio::test]
13092 async fn multipart_send_accepts_part_name_placeholder() -> Result<(), Box<dyn std::error::Error>>
13093 {
13094 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13095 let mut files = BTreeMap::new();
13096 files.insert(
13097 "image-field".to_string(),
13098 MultipartImageUpload {
13099 media_type: "image/png".to_string(),
13100 bytes: Bytes::from_static(b"png-data"),
13101 },
13102 );
13103 let mut params = json!({
13104 "member_id": "analyst",
13105 "content": [
13106 { "type": "text", "text": "describe" },
13107 {
13108 "type": "image_upload",
13109 "part_name": "image-field",
13110 "media_type": "image/png"
13111 }
13112 ]
13113 });
13114
13115 externalize_image_upload_placeholders(&mut params, files, store)
13116 .await
13117 .map_err(std::io::Error::other)?;
13118
13119 assert_eq!(params["content"][1]["type"], json!("image"));
13120 assert_eq!(params["content"][1]["source"], json!("blob"));
13121 assert_eq!(params["content"][1]["media_type"], json!("image/png"));
13122 Ok(())
13123 }
13124
13125 #[tokio::test]
13126 async fn multipart_send_rejects_placeholder_without_file()
13127 -> Result<(), Box<dyn std::error::Error>> {
13128 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13129 let mut params = json!({
13130 "content": [{
13131 "type": "image_upload",
13132 "upload_id": "missing",
13133 "media_type": "image/png"
13134 }]
13135 });
13136 let err = match externalize_image_upload_placeholders(&mut params, BTreeMap::new(), store)
13137 .await
13138 {
13139 Ok(()) => return Err(std::io::Error::other("missing file").into()),
13140 Err(err) => err,
13141 };
13142 assert!(err.contains("missing file part"), "unexpected error: {err}");
13143 Ok(())
13144 }
13145
13146 #[test]
13147 fn generated_runtime_ids_do_not_match_sibling_colon_identities() {
13148 assert!(!member_id_matches_durable_identity(
13149 "rt:review:singleton:0",
13150 "review:singleton"
13151 ));
13152 assert!(!member_id_matches_durable_identity(
13153 "review:singleton:gen1",
13154 "review:singleton"
13155 ));
13156 assert!(!member_id_matches_durable_identity(
13157 "review:singleton:1",
13158 "review:singleton"
13159 ));
13160 assert!(!member_id_matches_durable_identity(
13161 "rt:review:singleton:qa:0",
13162 "review:singleton"
13163 ));
13164 assert!(!member_id_matches_durable_identity(
13165 "review:singleton:qa",
13166 "review:singleton"
13167 ));
13168 }
13169}