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, ACTION_WORKGRAPH_MANAGE,
42 ACTION_WORKGRAPH_VIEW, AccessController, AccessGroup, AccessResource, AccessRule, AccessView,
43 AgentResourceAttributes,
44};
45use crate::blob_store::{BinaryBlobPayload, BinaryBlobStore, is_valid_blob_id_value};
46use crate::console_aggregator::{
47 ConsoleCursor, ConsoleFrame, ConsoleIdentityRecord, ConsoleLogError, ConsoleLogResult,
48 ConsoleLogStore, ConsoleReplayUnavailable, ConsoleSendError, ConsoleSendRequest,
49 ConsoleTimelineEvent, ConsoleTimelineMode, ConsoleTimelineQuery, ConsoleTimelineWindowPage,
50 ConsoleTimelineWindowQuery, ConsoleVisibility, ConsoleVisibilityPolicy,
51 HideImplicitDelegateMembersConsoleVisibilityPolicy, MobKitConsoleAggregator,
52};
53use crate::contact_directory::ContactDirectory;
54use crate::http_sse::{DEFAULT_KEEP_ALIVE_INTERVAL, KEEP_ALIVE_TEXT};
55use crate::mob_handle_runtime::{
56 MEMBER_STATE_ACTIVE, MEMBER_STATE_RETIRING, MobRuntime, member_status_state_string,
57};
58use crate::rpc::memory_methods::{
59 parse_agent_memory_forget_params, parse_agent_memory_manifest_params,
60 parse_agent_memory_recall_params, parse_agent_memory_remember_params,
61 parse_agent_memory_update_params,
62};
63use crate::rpc::{JSONRPC_VERSION, JsonRpcError, JsonRpcRequest, JsonRpcResponse};
64use crate::runtime::MobkitRuntimeHandle;
65use crate::runtime::{
66 ConsoleAgentLiveSnapshot, ConsoleLiveSnapshot, ConsoleMember, ConsoleModelCapabilities,
67 ConsoleRestJsonRequest, DeliveryHistoryRequest, GatingDecideRequest, GatingDecision,
68 RuntimeDecisionState, extract_bearer_token_from_header,
69 handle_console_rest_json_route_with_snapshot_access_memory_and_workgraph,
70 resolve_authorized_console_auth_from_token,
71};
72use crate::runtime::{MetadataScope, RuntimeMetadataTable, labels_to_json_value};
73use crate::types::{EventEnvelope, UnifiedEvent};
74use crate::unified_runtime::console_events::ConsoleEventStore;
75use crate::unified_runtime::mob_events::MobEventsStore;
76use crate::unified_runtime::{EventLogStore, EventQuery};
77
78#[derive(Clone)]
79pub struct ConsoleJsonState {
80 pub decisions: RuntimeDecisionState,
81 pub runtime: Option<MobRuntime>,
82 pub module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
83 pub contact_directory: Option<ContactDirectory>,
84 pub event_log: Option<std::sync::Arc<dyn EventLogStore>>,
85 pub gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
89 pub(crate) identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
90 pub(crate) console_events: Option<ConsoleEventStore>,
91 pub(crate) console_aggregator: Option<MobKitConsoleAggregator>,
92 pub(crate) mob_events: Option<MobEventsStore>,
93 pub(crate) metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
94 pub(crate) visibility_policy: Arc<dyn ConsoleVisibilityPolicy>,
95 pub(crate) snapshot_read_model: ConsoleSnapshotReadModel,
96 pub(crate) access: Option<AccessController>,
99 pub(crate) memory_panel: Option<crate::memory::sqlite_store::SqliteAgentMemoryStore>,
103 pub(crate) operator_resolver:
108 Option<Arc<crate::memory::coordinator::ConsolePrincipalOperatorResolver>>,
109 pub(crate) identity_roster: Option<Arc<crate::identity_first::MutableRosterProvider>>,
113 pub(crate) workgraph: Option<meerkat::WorkGraphService>,
121 pub(crate) topology: Option<crate::topology_control::TopologyRuntimeHandle>,
125}
126
127#[derive(Debug, Clone)]
128struct ConsoleHttpAuthContext {
129 principal: Option<String>,
130 access_view: Option<AccessView>,
132}
133
134#[derive(Clone, Default)]
135pub(crate) struct ConsoleSnapshotReadModel {
136 inner: Arc<tokio::sync::RwLock<ConsoleSnapshotReadModelState>>,
137 refresh_lock: Arc<tokio::sync::Mutex<()>>,
143 primed: Arc<std::sync::atomic::AtomicBool>,
147}
148
149#[derive(Clone, Default)]
150struct ConsoleSnapshotReadModelState {
151 running: Option<bool>,
152 session_id_by_identity: BTreeMap<String, String>,
153 session_owner_by_id: BTreeMap<String, String>,
154 primary_members: Vec<ConsoleMember>,
159 delegate_member_groups: Vec<Vec<ConsoleMember>>,
164}
165
166impl ConsoleSnapshotReadModel {
167 async fn snapshot(&self, runtime: &MobRuntime) -> ConsoleSnapshotReadModelState {
174 if !self.primed.load(std::sync::atomic::Ordering::Acquire) {
175 Box::pin(self.prime_now(runtime)).await;
176 }
177 self.inner.read().await.clone()
178 }
179
180 async fn prime_now(&self, runtime: &MobRuntime) {
200 if self.primed.load(std::sync::atomic::Ordering::Acquire) {
201 return;
202 }
203 let _guard = self.refresh_lock.clone().lock_owned().await;
204 if self.primed.load(std::sync::atomic::Ordering::Acquire) {
205 return;
206 }
207 let refreshed = Box::pin(collect_console_snapshot_read_model(runtime)).await;
208 *self.inner.write().await = refreshed;
209 self.primed
210 .store(true, std::sync::atomic::Ordering::Release);
211 }
215
216 fn refresh_soon(&self, runtime: MobRuntime) {
223 let Ok(runtime_handle) = tokio::runtime::Handle::try_current() else {
224 return;
225 };
226 let Ok(guard) = self.refresh_lock.clone().try_lock_owned() else {
227 return;
228 };
229 let inner = Arc::clone(&self.inner);
230 let primed = Arc::clone(&self.primed);
231 runtime_handle.spawn(async move {
232 let _guard = guard;
233 let refreshed = Box::pin(collect_console_snapshot_read_model(&runtime)).await;
234 *inner.write().await = refreshed;
235 primed.store(true, std::sync::atomic::Ordering::Release);
236 });
240 }
241}
242
243const CONSOLE_FRONTEND_INDEX_HTML: &str = include_str!("../console-dist/index.html");
244const CONSOLE_FRONTEND_APP_JS: &str = include_str!("../console-dist/console-app.js");
245const CONSOLE_FRONTEND_APP_CSS: &str = include_str!("../console-dist/console-app.css");
246const MAX_MULTIPART_IMAGE_BYTES: usize = 25 * 1024 * 1024;
247const MAX_MULTIPART_IMAGES: usize = 4;
248const MAX_MULTIPART_BODY_BYTES: usize =
249 (MAX_MULTIPART_IMAGE_BYTES * MAX_MULTIPART_IMAGES) + 1024 * 1024;
250
251pub fn console_json_router(decisions: RuntimeDecisionState) -> Router {
252 console_json_router_with_state(ConsoleJsonState {
253 decisions,
254 runtime: None,
255 module_runtime: None,
256 contact_directory: None,
257 event_log: None,
258 gateway_peer_keys: None,
259 identity_runtime: None,
260 console_events: None,
261 console_aggregator: None,
262 mob_events: None,
263 metadata_table: None,
264 visibility_policy: Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
265 snapshot_read_model: ConsoleSnapshotReadModel::default(),
266 access: None,
267 memory_panel: None,
268 operator_resolver: None,
269 identity_roster: None,
270 workgraph: None,
271 topology: None,
272 })
273}
274
275pub fn console_json_router_with_aggregator(
276 decisions: RuntimeDecisionState,
277 console_aggregator: MobKitConsoleAggregator,
278) -> Router {
279 console_json_router_with_aggregator_and_access(decisions, console_aggregator, None)
280}
281
282pub fn console_json_router_with_aggregator_and_access(
283 decisions: RuntimeDecisionState,
284 console_aggregator: MobKitConsoleAggregator,
285 access: Option<AccessController>,
286) -> Router {
287 console_json_router_with_state(ConsoleJsonState {
288 decisions,
289 runtime: None,
290 module_runtime: None,
291 contact_directory: None,
292 event_log: None,
293 gateway_peer_keys: None,
294 identity_runtime: None,
295 console_events: None,
296 console_aggregator: Some(console_aggregator),
297 mob_events: None,
298 metadata_table: None,
299 visibility_policy: Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
300 snapshot_read_model: ConsoleSnapshotReadModel::default(),
301 access,
302 memory_panel: None,
303 operator_resolver: None,
304 identity_roster: None,
305 workgraph: None,
306 topology: None,
307 })
308}
309
310pub fn console_json_router_with_runtime(
311 decisions: RuntimeDecisionState,
312 runtime: MobRuntime,
313 contact_directory: Option<ContactDirectory>,
314 event_log: Option<std::sync::Arc<dyn EventLogStore>>,
315) -> Router {
316 console_json_router_with_runtime_and_events(
317 decisions,
318 runtime,
319 None,
320 contact_directory,
321 event_log,
322 None,
323 None,
324 None,
325 None,
326 None,
327 None,
328 )
329}
330
331#[allow(clippy::too_many_arguments)]
332pub(crate) fn console_json_router_with_runtime_and_events(
333 decisions: RuntimeDecisionState,
334 runtime: MobRuntime,
335 module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
336 contact_directory: Option<ContactDirectory>,
337 event_log: Option<std::sync::Arc<dyn EventLogStore>>,
338 gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
339 console_events: Option<ConsoleEventStore>,
340 console_log_store: Option<std::sync::Arc<dyn ConsoleLogStore>>,
341 mob_events: Option<MobEventsStore>,
342 metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
343 identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
344) -> Router {
345 console_json_router_with_runtime_events_and_policy(
346 decisions,
347 runtime,
348 module_runtime,
349 contact_directory,
350 event_log,
351 gateway_peer_keys,
352 console_events,
353 console_log_store,
354 mob_events,
355 metadata_table,
356 identity_runtime,
357 Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
358 None,
359 None,
360 None,
361 None,
362 None,
363 None,
364 )
365}
366
367#[allow(clippy::too_many_arguments)]
368pub(crate) fn console_json_router_with_runtime_events_and_policy(
369 decisions: RuntimeDecisionState,
370 runtime: MobRuntime,
371 module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
372 contact_directory: Option<ContactDirectory>,
373 event_log: Option<std::sync::Arc<dyn EventLogStore>>,
374 gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
375 console_events: Option<ConsoleEventStore>,
376 console_log_store: Option<std::sync::Arc<dyn ConsoleLogStore>>,
377 mob_events: Option<MobEventsStore>,
378 metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
379 identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
380 visibility_policy: Arc<dyn ConsoleVisibilityPolicy>,
381 access: Option<AccessController>,
382 memory_panel: Option<crate::memory::sqlite_store::SqliteAgentMemoryStore>,
383 operator_resolver: Option<Arc<crate::memory::coordinator::ConsolePrincipalOperatorResolver>>,
384 identity_roster: Option<Arc<crate::identity_first::MutableRosterProvider>>,
385 workgraph: Option<meerkat::WorkGraphService>,
386 topology: Option<crate::topology_control::TopologyRuntimeHandle>,
387) -> Router {
388 let console_aggregator = console_events.clone().map(|events| {
389 if let Some(store) = console_log_store {
390 let aggregator = MobKitConsoleAggregator::new(store);
391 aggregator.register_runtime_handles_with_policy(
392 "default",
393 "",
394 runtime.clone(),
395 identity_runtime.clone(),
396 events,
397 visibility_policy.clone(),
398 );
399 aggregator
400 } else {
401 let aggregator = MobKitConsoleAggregator::in_memory();
402 aggregator.register_runtime_handles_with_policy(
403 "default",
404 "",
405 runtime.clone(),
406 identity_runtime.clone(),
407 events,
408 visibility_policy.clone(),
409 );
410 aggregator
411 }
412 });
413 let snapshot_read_model = ConsoleSnapshotReadModel::default();
414 snapshot_read_model.refresh_soon(runtime.clone());
415 let workgraph = workgraph.or_else(|| runtime.workgraph_service());
418 console_json_router_with_state(ConsoleJsonState {
419 decisions,
420 runtime: Some(runtime),
421 module_runtime,
422 contact_directory,
423 event_log,
424 gateway_peer_keys,
425 identity_runtime,
426 console_events,
427 console_aggregator,
428 mob_events,
429 metadata_table,
430 visibility_policy,
431 snapshot_read_model,
432 access,
433 memory_panel,
434 operator_resolver,
435 identity_roster,
436 workgraph,
437 topology,
438 })
439}
440
441pub fn console_frontend_router() -> Router {
442 Router::new()
443 .route("/", get(|| async { Redirect::temporary("/console") }))
444 .route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT }))
445 .route("/console", get(console_frontend_index_handler))
446 .route("/console/", get(console_frontend_index_handler))
447 .route(
448 "/console/assets/console-app.js",
449 get(console_frontend_app_js_handler),
450 )
451 .route(
452 "/console/assets/console-app.css",
453 get(console_frontend_app_css_handler),
454 )
455}
456
457fn console_json_router_with_state(state: ConsoleJsonState) -> Router {
458 let router = Router::new()
459 .route("/console/experience", get(console_json_handler))
460 .route("/console/modules", get(console_json_handler))
461 .route("/console/identities", get(console_identities_handler))
462 .route("/console/timeline", get(console_timeline_handler))
463 .route(
464 "/console/timeline/stream",
465 get(console_timeline_stream_handler),
466 )
467 .route(
468 "/console/identity/{identity}/stream",
469 get(console_identity_timeline_stream_handler),
470 )
471 .route("/console/send", post(console_send_handler))
472 .route("/console/rpc", post(console_rpc_handler))
473 .route(
474 "/console/rpc/multipart",
475 post(console_rpc_multipart_handler)
476 .layer(DefaultBodyLimit::max(MAX_MULTIPART_BODY_BYTES)),
477 )
478 .route("/blobs/{blob_id}", get(blob_get_handler));
479 router.with_state(state)
480}
481
482pub async fn console_json_handler(
483 State(state): State<ConsoleJsonState>,
484 headers: HeaderMap,
485 uri: Uri,
486) -> impl IntoResponse {
487 let mut path = uri
488 .path_and_query()
489 .map(|path_and_query| path_and_query.as_str().to_string())
490 .unwrap_or_else(|| uri.path().to_string());
491
492 let already_has_token = path
504 .split_once('?')
505 .map(|(_, q)| form_urlencoded::parse(q.as_bytes()).any(|(key, _)| key == "auth_token"))
506 .unwrap_or(false);
507 if !already_has_token
508 && let Some(bearer) = headers
509 .get(header::AUTHORIZATION)
510 .and_then(|v| v.to_str().ok())
511 .and_then(extract_bearer_token_from_header)
512 {
513 let encoded: String = form_urlencoded::byte_serialize(bearer.as_bytes()).collect();
514 let sep = if path.contains('?') { '&' } else { '?' };
515 path = format!("{path}{sep}auth_token={encoded}");
516 }
517
518 let config_module_ids: Vec<String> = state
519 .decisions
520 .modules
521 .iter()
522 .map(|m| m.id.clone())
523 .collect();
524 let live_snapshot = match &state.runtime {
525 Some(runtime) => {
526 state.snapshot_read_model.refresh_soon(runtime.clone());
527 Some(
528 Box::pin(build_live_snapshot(
529 runtime,
530 &config_module_ids,
531 state.console_events.as_ref(),
532 state.visibility_policy.as_ref(),
533 &state.snapshot_read_model,
534 ))
535 .await,
536 )
537 }
538 None => match &state.console_aggregator {
539 Some(aggregator) => Box::pin(build_aggregator_live_snapshot(
540 aggregator,
541 &config_module_ids,
542 ))
543 .await
544 .ok(),
545 None => None,
546 },
547 }
548 .map(|mut snapshot| {
549 apply_console_visibility_policy(&mut snapshot, state.visibility_policy.as_ref());
550 snapshot
551 });
552
553 let response = handle_console_rest_json_route_with_snapshot_access_memory_and_workgraph(
554 &state.decisions,
555 &ConsoleRestJsonRequest {
556 method: "GET".to_string(),
557 path,
558 auth: None,
559 },
560 live_snapshot.as_ref(),
561 state.access.as_ref(),
562 state.memory_panel.is_some(),
563 state.workgraph.is_some(),
564 );
565 let status = StatusCode::from_u16(response.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
566 (status, Json::<Value>(response.body))
567}
568
569pub async fn console_rpc_handler(
570 State(state): State<ConsoleJsonState>,
571 headers: HeaderMap,
572 uri: Uri,
573 Json(request): Json<Value>,
574) -> impl IntoResponse {
575 let parsed_request = match serde_json::from_value::<JsonRpcRequest>(request) {
577 Ok(req) => req,
578 Err(_) => {
579 return (
580 StatusCode::OK,
581 Json::<Value>(serde_json::json!({
582 "jsonrpc": JSONRPC_VERSION,
583 "id": Value::Null,
584 "error": { "code": -32600, "message": "Invalid Request" }
585 })),
586 );
587 }
588 };
589
590 let auth_context = match console_request_auth_context(&state, &headers, &uri) {
596 Some(context) => context,
597 None => {
598 return (
599 StatusCode::UNAUTHORIZED,
600 Json::<Value>(serde_json::json!({
601 "jsonrpc": JSONRPC_VERSION,
602 "id": parsed_request.id.unwrap_or(Value::Null),
603 "error": {
604 "code": -32600,
605 "message": "unauthorized: console rpc requires a valid auth token",
606 }
607 })),
608 );
609 }
610 };
611 let is_authenticated = true;
616 let read_only = state.decisions.console.read_only;
617 let Some(runtime) = &state.runtime else {
618 let response_value = Box::pin(handle_console_aggregator_rpc(
619 state.console_aggregator.clone(),
620 parsed_request,
621 is_authenticated,
622 read_only,
623 state.access.as_ref(),
624 auth_context.access_view.as_ref(),
625 ))
626 .await;
627 return (StatusCode::OK, Json::<Value>(response_value));
628 };
629
630 let response_value = Box::pin(handle_console_runtime_rpc_with_visibility(
631 runtime,
632 state.module_runtime.clone(),
633 state.contact_directory.as_ref(),
634 state.gateway_peer_keys.as_ref(),
635 state.console_events.clone(),
636 state.console_aggregator.clone(),
637 state.identity_runtime.clone(),
638 state.metadata_table.clone(),
639 state.mob_events.clone(),
640 state.visibility_policy.as_ref(),
641 parsed_request,
642 is_authenticated,
643 read_only,
644 auth_context.principal.as_deref(),
645 state.access.as_ref(),
646 auth_context.access_view.as_ref(),
647 state.memory_panel.as_ref(),
648 state.identity_roster.clone(),
649 state.workgraph.as_ref(),
650 state.topology.as_ref(),
651 ))
652 .await;
653 (StatusCode::OK, Json::<Value>(response_value))
654}
655
656#[derive(Debug, serde::Deserialize)]
657struct ConsoleTimelineHttpQuery {
658 #[serde(default)]
659 identity: Option<String>,
660 #[serde(default)]
661 conversation_id: Option<String>,
662 #[serde(default)]
663 after: Option<String>,
664 #[serde(default)]
665 before: Option<String>,
666 #[serde(default)]
667 mode: Option<ConsoleTimelineMode>,
668 #[serde(default)]
669 limit: Option<usize>,
670}
671
672async fn console_identities_handler(
673 State(state): State<ConsoleJsonState>,
674 headers: HeaderMap,
675 uri: Uri,
676) -> impl IntoResponse {
677 let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
678 return console_json_error(
679 StatusCode::UNAUTHORIZED,
680 "unauthorized",
681 "console identities require a valid auth token",
682 );
683 };
684 let Some(aggregator) = &state.console_aggregator else {
685 return console_json_error(
686 StatusCode::NOT_FOUND,
687 "unavailable",
688 "console aggregator unavailable",
689 );
690 };
691 let aggregator = aggregator.clone();
692 match Box::pin(aggregator.list_identities()).await {
693 Ok(mut identities) => {
694 retain_visible_identity_records(&mut identities, auth_context.access_view.as_ref());
695 (
696 StatusCode::OK,
697 Json::<Value>(json!({ "identities": identities })),
698 )
699 .into_response()
700 }
701 Err(err) => {
702 tracing::warn!(target: "mobkit::console", error = %err, "console identities request failed");
703 console_json_error(
704 StatusCode::INTERNAL_SERVER_ERROR,
705 "internal_error",
706 "console identities unavailable",
707 )
708 }
709 }
710}
711
712async fn console_timeline_handler(
713 State(state): State<ConsoleJsonState>,
714 headers: HeaderMap,
715 uri: Uri,
716 Query(query): Query<ConsoleTimelineHttpQuery>,
717) -> impl IntoResponse {
718 let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
719 return console_json_error(
720 StatusCode::UNAUTHORIZED,
721 "unauthorized",
722 "console timeline requires a valid auth token",
723 );
724 };
725 let Some(aggregator) = &state.console_aggregator else {
726 return console_json_error(
727 StatusCode::NOT_FOUND,
728 "unavailable",
729 "console aggregator unavailable",
730 );
731 };
732 if let Some(runtime) = &state.runtime
736 && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
737 {
738 prime_access_cache_from_runtime(runtime, controller).await;
739 }
740 let timeline_query = timeline_query_from_http(query, None);
741 match Box::pin(aggregator.query_timeline_windowed(timeline_query)).await {
742 Ok(mut page) => {
743 if let Some(view) = auth_context.access_view.as_ref() {
744 page.frames
745 .retain(|frame| view.can_view_agent(frame.identity.as_str()));
746 }
747 (
748 StatusCode::OK,
749 Json::<Value>(
750 serde_json::to_value(page).unwrap_or_else(|_| json!({ "frames": [] })),
751 ),
752 )
753 .into_response()
754 }
755 Err(err) => {
756 console_json_error(StatusCode::CONFLICT, "replay_unavailable", &err.to_string())
757 }
758 }
759}
760
761async fn console_send_handler(
762 State(state): State<ConsoleJsonState>,
763 headers: HeaderMap,
764 uri: Uri,
765 Json(request): Json<ConsoleSendRequest>,
766) -> impl IntoResponse {
767 let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
768 return console_json_error(
769 StatusCode::UNAUTHORIZED,
770 "unauthorized",
771 "console send requires a valid auth token",
772 );
773 };
774 if state.decisions.console.read_only {
775 return console_json_error(StatusCode::FORBIDDEN, "read_only", "console is read-only");
776 }
777 if let Some(runtime) = &state.runtime
785 && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
786 {
787 prime_access_cache_from_runtime(runtime, controller).await;
788 }
789 if let Some(view) = auth_context.access_view.as_ref()
790 && view.enforced()
791 && !view.allows_agent(ACTION_AGENT_SEND, request.identity.as_str())
792 {
793 return console_json_error(
794 StatusCode::FORBIDDEN,
795 "access_denied",
796 "you are not allowed to send to this agent",
797 );
798 }
799 if let Some(resolver) = state.operator_resolver.as_ref()
804 && let Some(subject) = auth_context
805 .access_view
806 .as_ref()
807 .and_then(|view| view.subject())
808 {
809 resolver.note_interaction(request.identity.as_str(), subject);
810 }
811 let Some(aggregator) = &state.console_aggregator else {
812 return console_json_error(
813 StatusCode::NOT_FOUND,
814 "unavailable",
815 "console aggregator unavailable",
816 );
817 };
818 if let Some(identity_runtime) = &state.identity_runtime {
819 return match Box::pin(console_send_with_identity_first_fallback(
820 aggregator,
821 identity_runtime.clone(),
822 state.runtime.as_ref(),
823 state.console_events.as_ref(),
824 request,
825 ))
826 .await
827 {
828 Ok(accepted) => (
829 StatusCode::OK,
830 Json::<Value>(
831 serde_json::to_value(accepted).unwrap_or_else(|_| json!({ "accepted": true })),
832 ),
833 )
834 .into_response(),
835 Err(err) => console_send_error_response(err),
836 };
837 }
838 match Box::pin(aggregator.send(request)).await {
839 Ok(accepted) => (
840 StatusCode::OK,
841 Json::<Value>(
842 serde_json::to_value(accepted).unwrap_or_else(|_| json!({ "accepted": true })),
843 ),
844 )
845 .into_response(),
846 Err(err) => console_send_error_response(err),
847 }
848}
849
850async fn console_send_with_identity_first_fallback(
851 aggregator: &MobKitConsoleAggregator,
852 identity_runtime: Arc<crate::identity_first::IdentityRuntime>,
853 runtime: Option<&MobRuntime>,
854 console_events: Option<&ConsoleEventStore>,
855 request: ConsoleSendRequest,
856) -> Result<crate::console_aggregator::ConsoleInteractionAccepted, ConsoleSendError> {
857 let member_send_request = request.clone();
858 match Box::pin(console_send_identity_first(
859 aggregator,
860 identity_runtime,
861 runtime,
862 console_events,
863 request,
864 ))
865 .await
866 {
867 Err(ConsoleSendError::UnknownIdentity(_)) => {
868 Box::pin(aggregator.send(member_send_request)).await
869 }
870 result => result,
871 }
872}
873
874async fn console_send_identity_first(
875 aggregator: &MobKitConsoleAggregator,
876 identity_runtime: Arc<crate::identity_first::IdentityRuntime>,
877 runtime: Option<&MobRuntime>,
878 console_events: Option<&ConsoleEventStore>,
879 mut request: ConsoleSendRequest,
880) -> Result<crate::console_aggregator::ConsoleInteractionAccepted, ConsoleSendError> {
881 let requested_identity = request.identity.clone();
882 let parsed_identity = crate::identity_first::AgentIdentity::parse(request.identity.as_str())
883 .map_err(|err| ConsoleSendError::InvalidRequest(format!("invalid identity: {err}")))?;
884 let content: ContentInput = serde_json::from_value(request.content.clone())
885 .map_err(|err| ConsoleSendError::InvalidContent(err.to_string()))?;
886 if let ContentInput::Text(text) = &content
887 && text.trim().is_empty()
888 {
889 return Err(ConsoleSendError::InvalidContent(
890 "content must be non-empty".to_string(),
891 ));
892 }
893 if let ContentInput::Blocks(blocks) = &content
894 && blocks.is_empty()
895 {
896 return Err(ConsoleSendError::InvalidContent(
897 "content blocks must be non-empty".to_string(),
898 ));
899 }
900 let handling_mode = parse_identity_first_handling_mode(request.handling_mode.as_deref())?;
901
902 let (identity, status) = match identity_runtime.status(&parsed_identity).await {
903 Ok(status) => (parsed_identity, status),
904 Err(original_err) => {
905 let Some(canonical_identity) = Box::pin(resolve_console_send_identity_alias(
906 aggregator,
907 &requested_identity,
908 ))
909 .await
910 else {
911 return Err(identity_runtime_error_to_console_send_error(
912 requested_identity.as_str(),
913 original_err,
914 ));
915 };
916 let identity = crate::identity_first::AgentIdentity::parse(canonical_identity.as_str())
917 .map_err(|err| {
918 ConsoleSendError::InvalidRequest(format!("invalid aliased identity: {err}"))
919 })?;
920 let status = identity_runtime.status(&identity).await.map_err(|_| {
921 identity_runtime_error_to_console_send_error(
922 requested_identity.as_str(),
923 original_err,
924 )
925 })?;
926 request.identity = canonical_identity;
927 (identity, status)
928 }
929 };
930 let session_id = status
931 .session_id
932 .as_ref()
933 .map(std::string::ToString::to_string);
934 let runtime_member_id = status
935 .agent_runtime_id
936 .as_ref()
937 .map(|id| id.as_str().to_string());
938 let accepted = Box::pin(
939 aggregator.reserve_identity_first_interaction(request.clone(), session_id.as_deref()),
940 )
941 .await?;
942
943 if let Some(events) = console_events {
944 events
945 .reserve_interaction_value(
946 identity.as_str(),
947 runtime_member_id.as_deref(),
948 &accepted.interaction_id,
949 &request.origin,
950 request.content.clone(),
951 )
952 .await
953 .map_err(ConsoleSendError::State)?;
954 }
955
956 if let (Some(runtime), Some(events), Some(runtime_member_id)) = (
957 runtime,
958 console_events.cloned(),
959 runtime_member_id.as_deref(),
960 ) {
961 start_identity_first_console_live_projection(
962 runtime,
963 events,
964 identity.as_str(),
965 runtime_member_id,
966 )
967 .await;
968 }
969
970 if handling_mode == meerkat_core::types::HandlingMode::Steer {
971 match identity_runtime
972 .send_with_mode_and_interaction(
973 &identity,
974 &content,
975 handling_mode,
976 Some(accepted.interaction_id.as_str()),
977 )
978 .await
979 {
980 Ok(_) => {
981 if let Err(err) = aggregator
982 .mark_steer_interaction_delivered(
983 &accepted.input_frame_id,
984 &accepted.interaction_id,
985 )
986 .await
987 {
988 tracing::warn!(
989 identity = %identity,
990 error = %err,
991 "console identity-first steer was admitted but delivery status projection failed"
992 );
993 }
994 }
995 Err(err) => {
996 let _ = aggregator
997 .mark_interaction_delivery_failed(&accepted.input_frame_id)
998 .await;
999 if let Some(events) = console_events {
1000 events
1001 .record_lifecycle(
1002 identity.as_str(),
1003 "interaction_failed",
1004 json!({
1005 "interaction_id": accepted.interaction_id,
1006 "origin": request.origin,
1007 "error": err.to_string(),
1008 }),
1009 )
1010 .await;
1011 }
1012 tracing::warn!(
1013 identity = %identity,
1014 error = %err,
1015 "console identity-first steer was accepted but delivery failed"
1016 );
1017 return Err(identity_runtime_error_to_console_send_error(
1018 identity.as_str(),
1019 err,
1020 ));
1021 }
1022 }
1023 return Ok(accepted);
1024 }
1025
1026 let dispatch_aggregator = aggregator.clone();
1027 let dispatch_events = console_events.cloned();
1028 let dispatch_identity = identity.clone();
1029 let dispatch_content = content.clone();
1030 let dispatch_origin = request.origin.clone();
1031 let dispatch_accepted = accepted.clone();
1032 tokio::spawn(async move {
1033 match identity_runtime
1034 .send_with_mode_and_interaction(
1035 &dispatch_identity,
1036 &dispatch_content,
1037 handling_mode,
1038 Some(dispatch_accepted.interaction_id.as_str()),
1039 )
1040 .await
1041 {
1042 Ok(_) => {
1043 if let Err(err) = dispatch_aggregator
1044 .mark_interaction_delivered(&dispatch_accepted.input_frame_id)
1045 .await
1046 {
1047 tracing::warn!(
1048 identity = %dispatch_identity,
1049 error = %err,
1050 "console identity-first send was accepted but delivery status projection failed"
1051 );
1052 }
1053 }
1054 Err(err) => {
1055 let _ = dispatch_aggregator
1056 .mark_interaction_delivery_failed(&dispatch_accepted.input_frame_id)
1057 .await;
1058 if let Some(events) = dispatch_events {
1059 events
1060 .record_lifecycle(
1061 dispatch_identity.as_str(),
1062 "interaction_failed",
1063 json!({
1064 "interaction_id": dispatch_accepted.interaction_id,
1065 "origin": dispatch_origin,
1066 "error": err.to_string(),
1067 }),
1068 )
1069 .await;
1070 }
1071 tracing::warn!(
1072 identity = %dispatch_identity,
1073 error = %err,
1074 "console identity-first send was accepted but delivery failed"
1075 );
1076 }
1077 }
1078 });
1079 Ok(accepted)
1080}
1081
1082async fn start_identity_first_console_live_projection(
1083 runtime: &MobRuntime,
1084 console_events: ConsoleEventStore,
1085 identity: &str,
1086 runtime_member_id: &str,
1087) {
1088 let identity = identity.to_string();
1089 let runtime_member_id = runtime_member_id.to_string();
1090 let mut stream = match runtime
1091 .handle()
1092 .subscribe_agent_events(&crate::member_comms_id::mob_member_id(
1093 runtime_member_id.as_str(),
1094 ))
1095 .await
1096 {
1097 Ok(stream) => stream,
1098 Err(err) => {
1099 tracing::warn!(
1100 identity = %identity,
1101 runtime_member_id = %runtime_member_id,
1102 error = %err,
1103 "console identity-first live projection could not subscribe to agent events"
1104 );
1105 return;
1106 }
1107 };
1108
1109 tokio::spawn(async move {
1110 while let Some(envelope) = stream.next().await {
1111 let event_type = agent_event_type(&envelope.payload).to_string();
1112 let unified = EventEnvelope {
1113 event_id: format!("evt-agent-{}", envelope.event_id),
1114 source: "agent".to_string(),
1115 timestamp_ms: envelope.timestamp_ms,
1116 event: UnifiedEvent::Agent {
1117 agent_id: runtime_member_id.clone(),
1118 event_type: event_type.clone(),
1119 payload: Some(crate::mob_handle_runtime::console_agent_event_payload(
1124 &envelope.payload,
1125 )),
1126 },
1127 };
1128 console_events.project_unified_event(&unified).await;
1129 if matches!(event_type.as_str(), "run_completed" | "run_failed") {
1130 break;
1131 }
1132 }
1133 });
1134}
1135
1136fn parse_identity_first_handling_mode(
1137 value: Option<&str>,
1138) -> Result<meerkat_core::types::HandlingMode, ConsoleSendError> {
1139 match value.unwrap_or("queue") {
1140 "queue" => Ok(meerkat_core::types::HandlingMode::Queue),
1141 "steer" => Ok(meerkat_core::types::HandlingMode::Steer),
1142 other => Err(ConsoleSendError::InvalidHandlingMode(other.to_string())),
1143 }
1144}
1145
1146async fn resolve_console_send_identity_alias(
1147 aggregator: &MobKitConsoleAggregator,
1148 requested_identity: &str,
1149) -> Option<String> {
1150 let identities = Box::pin(aggregator.list_identities()).await.ok()?;
1151 identities
1152 .into_iter()
1153 .find(|record| {
1154 record.identity == requested_identity || record.runtime_member_id == requested_identity
1155 })
1156 .map(|record| record.identity)
1157}
1158
1159fn identity_runtime_error_to_console_send_error(
1160 identity: &str,
1161 err: crate::identity_first::IdentityRuntimeError,
1162) -> ConsoleSendError {
1163 match err {
1164 crate::identity_first::IdentityRuntimeError::UnknownIdentity(_) => {
1165 ConsoleSendError::UnknownIdentity(identity.to_string())
1166 }
1167 crate::identity_first::IdentityRuntimeError::NotAddressable(_) => {
1168 ConsoleSendError::NotAddressable(identity.to_string())
1169 }
1170 crate::identity_first::IdentityRuntimeError::InvalidState { .. } => {
1171 ConsoleSendError::Retired(identity.to_string())
1172 }
1173 other => ConsoleSendError::Dispatch(other.to_string()),
1174 }
1175}
1176
1177async fn console_timeline_stream_handler(
1178 State(state): State<ConsoleJsonState>,
1179 headers: HeaderMap,
1180 uri: Uri,
1181 Query(query): Query<ConsoleTimelineHttpQuery>,
1182) -> impl IntoResponse {
1183 let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
1184 return console_json_error(
1185 StatusCode::UNAUTHORIZED,
1186 "unauthorized",
1187 "console timeline stream requires a valid auth token",
1188 );
1189 };
1190 let access_view = auth_context.access_view.filter(AccessView::enforced);
1191 if access_view.is_some()
1198 && let Some(runtime) = &state.runtime
1199 && let Some(controller) = state
1200 .access
1201 .as_ref()
1202 .filter(|controller| controller.enabled())
1203 {
1204 prime_access_cache_from_runtime(runtime, controller).await;
1205 }
1206 let Some(aggregator) = &state.console_aggregator else {
1207 return console_json_error(
1208 StatusCode::NOT_FOUND,
1209 "unavailable",
1210 "console aggregator unavailable",
1211 );
1212 };
1213 let aggregator = aggregator.clone();
1214 let last_event_id = headers
1215 .get("last-event-id")
1216 .and_then(|value| value.to_str().ok())
1217 .map(str::trim)
1218 .filter(|value| !value.is_empty())
1219 .map(ToString::to_string);
1220 let timeline_query = timeline_query_from_http(query, last_event_id);
1221 let mut rx = aggregator.subscribe();
1222 let (snapshot_frames, snapshot_cursor) =
1223 match Box::pin(query_timeline_snapshot(&aggregator, timeline_query.clone())).await {
1224 Ok(snapshot) => snapshot,
1225 Err(_) => {
1226 let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
1227 let requested_cursor = timeline_query
1228 .after
1229 .as_ref()
1230 .map(ToString::to_string)
1231 .unwrap_or_default();
1232 return (
1233 StatusCode::CONFLICT,
1234 Json::<Value>(
1235 serde_json::to_value(ConsoleReplayUnavailable {
1236 error: "replay_unavailable".to_string(),
1237 requested_cursor,
1238 latest_cursor,
1239 })
1240 .unwrap_or_else(|_| json!({ "error": "replay_unavailable" })),
1241 ),
1242 )
1243 .into_response();
1244 }
1245 };
1246 let identity = timeline_query.identity.clone();
1247 let conversation_id = timeline_query.conversation_id.clone();
1248 let snapshot_after = timeline_query.after.clone();
1249 let stream = stream! {
1250 if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::SnapshotStarted { after: snapshot_after }) {
1251 yield Ok::<Event, Infallible>(event);
1252 }
1253 let mut latest_cursor = snapshot_cursor;
1254 for frame in snapshot_frames {
1255 latest_cursor = Some(frame.cursor.clone());
1256 if access_view.as_ref().is_some_and(|view| !view.can_view_agent(frame.identity.as_str())) {
1257 continue;
1258 }
1259 if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::ConsoleFrame { frame }) {
1260 yield Ok::<Event, Infallible>(event);
1261 }
1262 }
1263 if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::SnapshotComplete { cursor: latest_cursor.clone() }) {
1264 yield Ok::<Event, Infallible>(event);
1265 }
1266 loop {
1267 match rx.recv().await {
1268 Ok(event) if timeline_event_matches(&event, identity.as_deref(), conversation_id.as_deref()) => {
1269 if !Box::pin(
1270 aggregator
1271 .timeline_event_visible_for_subscriber(&event, identity.as_deref()),
1272 )
1273 .await
1274 {
1275 continue;
1276 }
1277 if let Some(view) = access_view.as_ref()
1278 && let Some(frame_identity) = timeline_event_identity(&event)
1279 && !view.can_view_agent(frame_identity)
1280 {
1281 continue;
1282 }
1283 if let Some(event_cursor) = timeline_event_cursor(&event)
1284 && let Some(current_cursor) = latest_cursor.as_ref()
1285 && !cursor_is_after(event_cursor, current_cursor)
1286 {
1287 continue;
1288 }
1289 if let Some(sse) = sse_event_from_timeline_event(&event) {
1290 if let Some(event_cursor) = timeline_event_cursor(&event) {
1291 latest_cursor = Some(event_cursor.clone());
1292 }
1293 yield Ok::<Event, Infallible>(sse);
1294 }
1295 }
1296 Ok(_) => {}
1297 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => break,
1298 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1299 }
1300 }
1301 };
1302 Sse::new(stream)
1303 .keep_alive(
1304 KeepAlive::new()
1305 .interval(DEFAULT_KEEP_ALIVE_INTERVAL)
1306 .text(KEEP_ALIVE_TEXT),
1307 )
1308 .into_response()
1309}
1310
1311async fn console_identity_timeline_stream_handler(
1312 State(state): State<ConsoleJsonState>,
1313 headers: HeaderMap,
1314 uri: Uri,
1315 AxumPath(identity): AxumPath<String>,
1316 Query(mut query): Query<ConsoleTimelineHttpQuery>,
1317) -> impl IntoResponse {
1318 query.identity = Some(identity);
1319 Box::pin(console_timeline_stream_handler(
1320 State(state),
1321 headers,
1322 uri,
1323 Query(query),
1324 ))
1325 .await
1326 .into_response()
1327}
1328
1329fn timeline_query_from_http(
1330 query: ConsoleTimelineHttpQuery,
1331 fallback_after: Option<String>,
1332) -> ConsoleTimelineWindowQuery {
1333 let after = fallback_after.or(query.after).map(ConsoleCursor::from);
1334 let before = query.before.map(ConsoleCursor::from);
1335 ConsoleTimelineWindowQuery {
1336 identity: query
1337 .identity
1338 .map(|value| value.trim().to_string())
1339 .filter(|value| !value.is_empty()),
1340 conversation_id: query
1341 .conversation_id
1342 .map(|value| value.trim().to_string())
1343 .filter(|value| !value.is_empty()),
1344 after,
1345 before,
1346 mode: query.mode.unwrap_or_default(),
1347 limit: query.limit.unwrap_or(200),
1348 }
1349}
1350
1351async fn query_timeline_snapshot(
1352 aggregator: &MobKitConsoleAggregator,
1353 mut query: ConsoleTimelineWindowQuery,
1354) -> ConsoleLogResult<(Vec<ConsoleFrame>, Option<ConsoleCursor>)> {
1355 const DEFAULT_SNAPSHOT_LIMIT: usize = 200;
1356 query.limit = if query.limit == 0 {
1357 DEFAULT_SNAPSHOT_LIMIT
1358 } else {
1359 query.limit
1360 };
1361 if query.after.is_none() && query.mode == ConsoleTimelineMode::Since {
1362 query.mode = ConsoleTimelineMode::Recent;
1363 }
1364 let mode = query.mode;
1365 match mode {
1366 ConsoleTimelineMode::Recent => {
1367 let page = Box::pin(aggregator.query_timeline_windowed(query)).await?;
1368 Ok((page.frames, page.latest_cursor.or(page.next_cursor)))
1369 }
1370 ConsoleTimelineMode::Since => {
1371 if let (Some(after), Some(latest)) =
1372 (query.after.as_ref(), aggregator.latest_cursor().await?)
1373 && let (Some(after_seq), Some(latest_seq)) = (after.seq(), latest.seq())
1374 && after_seq > latest_seq
1375 {
1376 return Err(std::io::Error::other(
1377 "timeline replay cursor is beyond the current store frontier",
1378 )
1379 .into());
1380 }
1381 let mut frames = Vec::new();
1382 let mut cursor = query.after.clone();
1383 let mut latest_cursor = None;
1384 loop {
1385 let page = Box::pin(aggregator.query_timeline_windowed(query.clone())).await?;
1386 latest_cursor = page.latest_cursor.clone().or(latest_cursor);
1387 if !page.frames.is_empty() {
1388 cursor = page
1389 .next_cursor
1390 .clone()
1391 .or_else(|| page.frames.last().map(|frame| frame.cursor.clone()));
1392 frames.extend(page.frames);
1393 } else if page.next_cursor.is_some() {
1394 cursor = page.next_cursor.clone();
1395 }
1396 if page.exhausted || page.next_cursor.is_none() {
1397 return Ok((frames, cursor.or(latest_cursor)));
1398 }
1399 if page.next_cursor == query.after {
1400 return Err(
1401 std::io::Error::other("timeline replay made no cursor progress").into(),
1402 );
1403 }
1404 query.after = page.next_cursor;
1405 }
1406 }
1407 }
1408}
1409
1410fn console_json_error(status: StatusCode, error: &str, message: &str) -> axum::response::Response {
1411 (
1412 status,
1413 Json::<Value>(json!({
1414 "error": error,
1415 "message": message,
1416 })),
1417 )
1418 .into_response()
1419}
1420
1421fn is_console_mutating_rpc_method(method: &str) -> bool {
1422 if crate::rpc::topology_methods::is_topology_mutating_method(method) {
1423 return true;
1424 }
1425 if crate::rpc::workgraph_methods::is_workgraph_mutating_method(method) {
1426 return true;
1427 }
1428 matches!(
1429 method,
1430 "mobkit/retire"
1431 | "mobkit/reset_all"
1432 | "mobkit/console/send"
1433 | "mobkit/blob/upload"
1434 | "mobkit/ensure_member"
1435 | "mobkit/retire_member"
1436 | "mobkit/respawn_member"
1437 | "mobkit/force_cancel_member"
1438 | "mobkit/cancel_flow"
1439 | "mobkit/collect_completed"
1440 | "mobkit/run_flow"
1441 | "mobkit/spawn_helper"
1442 | "mobkit/fork_helper"
1443 | "mobkit/attach_existing_session"
1444 | "mobkit/reconcile_edges"
1445 | "mobkit/cross_mob/wire_local"
1446 | "mobkit/cross_mob/unwire_local"
1447 | "mobkit/respawn"
1448 | "mobkit/reset"
1449 | "mobkit/delete_identity"
1450 | "mobkit/agent_memory/remember"
1451 | "mobkit/agent_memory/update"
1452 | "mobkit/agent_memory/forget"
1453 | "mobkit/gating/decide"
1454 | "mobkit/mob_labels/set"
1455 | "mobkit/mob_labels/delete"
1456 | "mobkit/run_labels/set"
1457 | "mobkit/run_labels/delete"
1458 | "mobkit/access/set"
1459 | "mobkit/access/enable"
1460 | "mobkit/access/rules/upsert"
1461 | "mobkit/access/rules/delete"
1462 | "mobkit/access/groups/set"
1463 | "mobkit/access/groups/delete"
1464 )
1465}
1466
1467fn console_read_only_rpc_error(response_id: Value) -> Value {
1468 response_value(
1469 response_id,
1470 None,
1471 Some(JsonRpcError {
1472 code: -32010,
1473 message: "console is read-only".to_string(),
1474 data: Some(json!({ "kind": "read_only" })),
1475 }),
1476 )
1477}
1478
1479fn console_send_error_response(err: ConsoleSendError) -> axum::response::Response {
1480 let (status, code) = match &err {
1481 ConsoleSendError::UnknownIdentity(_) => (StatusCode::NOT_FOUND, "unknown_identity"),
1482 ConsoleSendError::AmbiguousIdentity { .. } => {
1483 (StatusCode::CONFLICT, "ambiguous_live_identity_alias")
1484 }
1485 ConsoleSendError::NotAddressable(_) => (StatusCode::CONFLICT, "not_addressable"),
1486 ConsoleSendError::Retired(_) => (StatusCode::CONFLICT, "retired"),
1487 ConsoleSendError::InvalidContent(_)
1488 | ConsoleSendError::InvalidHandlingMode(_)
1489 | ConsoleSendError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"),
1490 ConsoleSendError::IdempotencyConflict(_) => (StatusCode::CONFLICT, "idempotency_conflict"),
1491 ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1492 (StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
1493 }
1494 };
1495 console_json_error(status, code, &console_send_public_message(&err))
1496}
1497
1498fn console_send_rpc_code(err: &ConsoleSendError) -> i64 {
1499 match err {
1500 ConsoleSendError::UnknownIdentity(_) => -32001,
1501 ConsoleSendError::AmbiguousIdentity { .. } => -32602,
1502 ConsoleSendError::NotAddressable(_) => -32002,
1503 ConsoleSendError::InvalidContent(_)
1504 | ConsoleSendError::InvalidHandlingMode(_)
1505 | ConsoleSendError::InvalidRequest(_) => -32602,
1506 ConsoleSendError::IdempotencyConflict(_) => -32009,
1507 ConsoleSendError::Retired(_) => -32004,
1508 ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1509 -32000
1510 }
1511 }
1512}
1513
1514fn console_send_rpc_error(response_id: Value, err: ConsoleSendError) -> Value {
1515 response_value(response_id, None, Some(console_send_json_rpc_error(err)))
1516}
1517
1518fn console_send_json_rpc_error(err: ConsoleSendError) -> JsonRpcError {
1519 let code = console_send_rpc_code(&err);
1520 JsonRpcError {
1521 code,
1522 message: console_send_public_message(&err),
1523 data: None,
1524 }
1525}
1526
1527fn console_send_public_message(err: &ConsoleSendError) -> String {
1528 match err {
1529 ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1530 tracing::warn!(target: "mobkit::console", error = %err, "console send internal error");
1531 "console send failed".to_string()
1532 }
1533 _ => err.to_string(),
1534 }
1535}
1536
1537fn timeline_event_matches(
1538 event: &ConsoleTimelineEvent,
1539 identity: Option<&str>,
1540 conversation_id: Option<&str>,
1541) -> bool {
1542 let frame = match event {
1543 ConsoleTimelineEvent::ConsoleFrame { frame }
1544 | ConsoleTimelineEvent::FrameUpdated { frame } => frame,
1545 ConsoleTimelineEvent::SnapshotStarted { .. }
1546 | ConsoleTimelineEvent::SnapshotComplete { .. }
1547 | ConsoleTimelineEvent::ReplayUnavailable { .. } => return true,
1548 };
1549 if identity.is_some_and(|value| frame.identity != value) {
1550 return false;
1551 }
1552 if conversation_id.is_some_and(|value| frame.conversation_id.as_deref() != Some(value)) {
1553 return false;
1554 }
1555 true
1556}
1557
1558fn timeline_event_cursor(event: &ConsoleTimelineEvent) -> Option<&ConsoleCursor> {
1559 match event {
1560 ConsoleTimelineEvent::ConsoleFrame { frame }
1561 | ConsoleTimelineEvent::FrameUpdated { frame } => Some(&frame.cursor),
1562 ConsoleTimelineEvent::SnapshotStarted { .. }
1563 | ConsoleTimelineEvent::SnapshotComplete { .. }
1564 | ConsoleTimelineEvent::ReplayUnavailable { .. } => None,
1565 }
1566}
1567
1568fn timeline_event_identity(event: &ConsoleTimelineEvent) -> Option<&str> {
1569 match event {
1570 ConsoleTimelineEvent::ConsoleFrame { frame }
1571 | ConsoleTimelineEvent::FrameUpdated { frame } => Some(frame.identity.as_str()),
1572 ConsoleTimelineEvent::SnapshotStarted { .. }
1573 | ConsoleTimelineEvent::SnapshotComplete { .. }
1574 | ConsoleTimelineEvent::ReplayUnavailable { .. } => None,
1575 }
1576}
1577
1578pub(crate) const ACCESS_DENIED_RPC_CODE: i64 = -32030;
1583
1584fn retain_visible_timeline_frames(page: &mut ConsoleTimelineWindowPage, view: Option<&AccessView>) {
1585 let Some(view) = view.filter(|view| view.enforced()) else {
1586 return;
1587 };
1588 page.frames
1589 .retain(|frame| view.can_view_agent(frame.identity.as_str()));
1590}
1591
1592fn retain_visible_member_rows(rows: &mut Vec<Value>, view: Option<&AccessView>) {
1596 let Some(view) = view.filter(|view| view.enforced()) else {
1597 return;
1598 };
1599 rows.retain(|row| {
1600 let labels: Option<std::collections::BTreeMap<String, String>> = row
1601 .get("labels")
1602 .and_then(|value| serde_json::from_value(value.clone()).ok());
1603 let agent_id = row
1604 .get("agent_identity")
1605 .or_else(|| row.get("member_id"))
1606 .or_else(|| row.get("agent_id"))
1607 .and_then(Value::as_str);
1608 let identity = labels
1609 .as_ref()
1610 .and_then(|labels| labels.get("agent_identity"))
1611 .map(String::as_str)
1612 .or_else(|| row.get("identity").and_then(Value::as_str))
1613 .or(agent_id);
1614 let role = row
1615 .get("role")
1616 .or_else(|| row.get("profile"))
1617 .and_then(Value::as_str);
1618 view.decide(
1619 ACTION_AGENT_VIEW,
1620 &AccessResource {
1621 identity,
1622 agent_id,
1623 role,
1624 labels: labels.as_ref(),
1625 },
1626 )
1627 .is_allow()
1628 });
1629}
1630
1631fn retain_visible_identity_records(
1632 identities: &mut Vec<ConsoleIdentityRecord>,
1633 view: Option<&AccessView>,
1634) {
1635 let Some(view) = view.filter(|view| view.enforced()) else {
1636 return;
1637 };
1638 identities.retain(|record| {
1639 view.decide(
1640 ACTION_AGENT_VIEW,
1641 &AccessResource {
1642 identity: Some(record.identity.as_str()),
1643 agent_id: Some(record.runtime_member_id.as_str()),
1644 role: None,
1645 labels: Some(&record.labels),
1646 },
1647 )
1648 .is_allow()
1649 });
1650}
1651
1652fn console_rpc_access_requirements(
1658 method: &str,
1659 params: &Value,
1660) -> Option<Vec<(&'static str, Option<String>)>> {
1661 let identity = normalized_console_rpc_string_param(params, "identity");
1662 let target = identity
1663 .clone()
1664 .or_else(|| normalized_console_rpc_string_param(params, "member_id"))
1665 .or_else(|| normalized_console_rpc_string_param(params, "agent_id"));
1666 let one = |action: &'static str, target: Option<String>| Some(vec![(action, target)]);
1667 match method {
1668 "mobkit/console/send" => one(ACTION_AGENT_SEND, identity),
1669 "mobkit/agent_memory/remember" => one(ACTION_AGENT_MEMORY_WRITE, identity),
1670 "mobkit/agent_memory/update" => one(ACTION_AGENT_MEMORY_WRITE, identity),
1672 "mobkit/agent_memory/forget" => one(ACTION_AGENT_MEMORY_DELETE, identity),
1673 "mobkit/agent_memory/recall" | "mobkit/agent_memory/manifest" => Some(vec![
1678 (ACTION_AGENT_MEMORY_READ, identity.clone()),
1679 (ACTION_AGENT_VIEW, identity),
1680 ]),
1681 "mobkit/memory/panel/records" => match memory_panel_scope_param(params) {
1688 Some(scope) if scope == "mob" => one(ACTION_MOB_MEMORY_READ, None),
1689 Some(scope) if scope == "operator" => one(ACTION_OPERATOR_MEMORY_READ, None),
1690 _ if identity.is_some() => Some(vec![
1691 (ACTION_AGENT_MEMORY_READ, identity.clone()),
1692 (ACTION_AGENT_VIEW, identity),
1693 ]),
1694 _ => None,
1695 },
1696 "mobkit/memory/panel/dreams" => one(ACTION_AGENT_MEMORY_READ, None),
1700 "mobkit/memory/panel/dream_runs" => one(ACTION_AGENT_MEMORY_READ, None),
1703 "mobkit/memory/panel/audit_verdicts" => one(ACTION_AGENT_MEMORY_READ, None),
1704 "mobkit/memory/panel/overview" => one(ACTION_AGENT_MEMORY_READ, None),
1709 "mobkit/memory/panel/proposals" => one(ACTION_AGENT_MEMORY_READ, None),
1710 "mobkit/memory/panel/injections" => one(ACTION_AGENT_MEMORY_READ, None),
1711 "mobkit/memory/panel/harvests" => one(ACTION_AGENT_MEMORY_READ, None),
1712 "mobkit/memory/panel/quarantine" => one(ACTION_MEMORY_QUARANTINE_REVIEW, None),
1713 "mobkit/retire"
1718 | "mobkit/retire_member"
1719 | "mobkit/force_cancel_member"
1720 | "mobkit/delete_identity" => one(ACTION_AGENT_RETIRE, target),
1721 "mobkit/respawn" | "mobkit/respawn_member" => one(ACTION_AGENT_RESPAWN, target),
1722 "mobkit/reset" | "mobkit/reset_all" => one(ACTION_AGENT_RESET, target),
1723 "mobkit/ensure_member"
1724 | "mobkit/spawn_helper"
1725 | "mobkit/fork_helper"
1726 | "mobkit/attach_existing_session"
1727 | "mobkit/run_flow"
1728 | "mobkit/cancel_flow"
1729 | "mobkit/collect_completed" => one(ACTION_AGENT_SPAWN, target),
1730 "mobkit/flow_status" | "mobkit/list_flows" | "mobkit/list_runs" => {
1733 one(ACTION_AGENT_SPAWN, None)
1734 }
1735 "mobkit/gating/decide" => one(ACTION_GATING_DECIDE, None),
1736 "mobkit/gating/pending" | "mobkit/gating/audit" => one(ACTION_GATING_VIEW, None),
1737 "mobkit/mob_events/query" | "mobkit/mob_events/subscribe" => one(ACTION_MOB_OBSERVE, None),
1738 "mobkit/reconcile_edges"
1739 | "mobkit/cross_mob/wire_local"
1740 | "mobkit/cross_mob/unwire_local"
1741 | "mobkit/mob_labels/set"
1742 | "mobkit/mob_labels/delete"
1743 | "mobkit/run_labels/set"
1744 | "mobkit/run_labels/delete"
1745 | "mobkit/routing/routes/list"
1750 | "mobkit/delivery/history"
1751 | "mobkit/cross_mob/directory"
1752 | "mobkit/cross_mob/peer_info"
1753 | "mobkit/mob_labels/get"
1754 | "mobkit/run_labels/get" => one(ACTION_RUNTIME_ADMIN, None),
1755 "mobkit/get_member"
1756 | "mobkit/member_status"
1757 | "mobkit/identity/resolved_tools"
1758 | "mobkit/inspect_identity"
1759 | "mobkit/status_identity"
1760 | "mobkit/console/inspect_identity" => one(ACTION_AGENT_VIEW, target),
1761 method if crate::rpc::workgraph_methods::is_workgraph_mutating_method(method) => {
1764 one(ACTION_WORKGRAPH_MANAGE, None)
1765 }
1766 method if crate::rpc::workgraph_methods::is_workgraph_read_method(method) => {
1767 one(ACTION_WORKGRAPH_VIEW, None)
1768 }
1769 _ => None,
1770 }
1771}
1772
1773fn memory_panel_scope_param(params: &Value) -> Option<String> {
1774 normalized_console_rpc_string_param(params, "scope").filter(|scope| !scope.is_empty())
1775}
1776
1777fn normalized_console_rpc_string_param(params: &Value, key: &str) -> Option<String> {
1778 params
1779 .get(key)
1780 .and_then(Value::as_str)
1781 .map(str::trim)
1782 .map(str::to_string)
1783}
1784
1785fn console_rpc_access_violation(
1788 view: Option<&AccessView>,
1789 method: &str,
1790 params: &Value,
1791) -> Option<JsonRpcError> {
1792 let view = view.filter(|view| view.enforced())?;
1793 let requirements = console_rpc_access_requirements(method, params)?;
1794 for (action, target) in requirements {
1795 let allowed = match target {
1796 Some(ref identity) => view.allows_agent(action, identity),
1797 None => view.allows(action),
1798 };
1799 if !allowed {
1800 return Some(JsonRpcError {
1801 code: ACCESS_DENIED_RPC_CODE,
1802 message: format!("access denied: {action}"),
1803 data: Some(json!({
1804 "kind": "access_denied",
1805 "action": action,
1806 "resource": target,
1807 })),
1808 });
1809 }
1810 }
1811 None
1812}
1813
1814fn access_denied_rpc_error(response_id: Value, message: impl Into<String>) -> Value {
1815 response_value(
1816 response_id,
1817 None,
1818 Some(JsonRpcError {
1819 code: ACCESS_DENIED_RPC_CODE,
1820 message: message.into(),
1821 data: Some(json!({ "kind": "access_denied" })),
1822 }),
1823 )
1824}
1825
1826fn access_unavailable_rpc_error(response_id: Value) -> Value {
1827 response_value(
1828 response_id,
1829 None,
1830 Some(JsonRpcError {
1831 code: -32004,
1832 message: "access control is not configured on this runtime".to_string(),
1833 data: Some(json!({ "kind": "access_unavailable" })),
1834 }),
1835 )
1836}
1837
1838fn access_config_rpc_error(response_id: Value, err: crate::access::AccessConfigError) -> Value {
1839 response_value(
1840 response_id,
1841 None,
1842 Some(JsonRpcError {
1843 code: -32602,
1844 message: err.to_string(),
1845 data: Some(json!({ "kind": "invalid_access_config" })),
1846 }),
1847 )
1848}
1849
1850const MEMORY_PANEL_DEFAULT_LIMIT: usize = 50;
1855const MEMORY_PANEL_MAX_LIMIT: usize = 200;
1856const MEMORY_PANEL_CHAIN_MAX: usize = 32;
1857const MEMORY_PANEL_INJECTIONS_MAX: usize = 50;
1858const MEMORY_PANEL_DREAMS_DEFAULT: usize = 20;
1859const MEMORY_PANEL_DREAMS_MAX: usize = 100;
1860
1861fn memory_panel_unavailable(response_id: Value) -> Value {
1862 response_value(
1863 response_id,
1864 None,
1865 Some(JsonRpcError {
1866 code: -32601,
1867 message: "memory panel is not configured".to_string(),
1868 data: None,
1869 }),
1870 )
1871}
1872
1873fn memory_panel_store_error(
1874 response_id: Value,
1875 err: crate::identity_first::agent_memory::AgentMemoryError,
1876) -> Value {
1877 response_value(
1878 response_id,
1879 None,
1880 Some(crate::rpc::agent_memory_rpc_error("panel", err)),
1881 )
1882}
1883
1884fn memory_panel_limit_param(params: &Value, default: usize, max: usize) -> usize {
1885 params
1886 .get("limit")
1887 .and_then(Value::as_u64)
1888 .map(|limit| (limit as usize).clamp(1, max))
1889 .unwrap_or(default)
1890}
1891
1892fn encode_memory_panel_cursor(cursor: &(u64, String)) -> String {
1893 format!("{}:{}", cursor.0, cursor.1)
1894}
1895
1896fn parse_memory_panel_cursor(raw: &str) -> Option<(u64, String)> {
1897 let (ms, id) = raw.split_once(':')?;
1898 let ms = ms.parse::<u64>().ok()?;
1899 (!id.is_empty()).then(|| (ms, id.to_string()))
1900}
1901
1902fn memory_panel_scope_action(scope: &crate::memory::records::MemoryScope) -> &'static str {
1907 match scope {
1908 crate::memory::records::MemoryScope::Identity { .. } => ACTION_AGENT_MEMORY_READ,
1909 crate::memory::records::MemoryScope::Mob { .. } => ACTION_MOB_MEMORY_READ,
1910 crate::memory::records::MemoryScope::Operator { .. } => ACTION_OPERATOR_MEMORY_READ,
1911 crate::memory::records::MemoryScope::Realm { .. } => ACTION_AGENT_MEMORY_READ,
1912 }
1913}
1914
1915fn memory_panel_record_visible(
1925 view: Option<&AccessView>,
1926 record: &crate::memory::records::MemoryRecord,
1927) -> bool {
1928 let Some(view) = view.filter(|view| view.enforced()) else {
1929 return true;
1930 };
1931 let scope_allowed = match &record.scope {
1932 crate::memory::records::MemoryScope::Identity { identity, .. } => {
1933 view.allows_agent(ACTION_AGENT_MEMORY_READ, identity)
1934 && view.allows_agent(ACTION_AGENT_VIEW, identity)
1935 }
1936 crate::memory::records::MemoryScope::Mob { .. } => view.allows(ACTION_MOB_MEMORY_READ),
1937 crate::memory::records::MemoryScope::Operator { .. } => {
1938 view.allows(ACTION_OPERATOR_MEMORY_READ)
1939 }
1940 crate::memory::records::MemoryScope::Realm { .. } => view.allows(ACTION_AGENT_MEMORY_READ),
1941 };
1942 if !scope_allowed {
1943 return false;
1944 }
1945 if matches!(
1946 record.status,
1947 crate::memory::records::RecordStatus::Quarantined { .. }
1948 ) {
1949 return view.allows(ACTION_MEMORY_QUARANTINE_REVIEW);
1950 }
1951 true
1952}
1953
1954fn memory_panel_promotion_visible(
1961 view: Option<&AccessView>,
1962 scope_kind: &str,
1963 scope_key: &str,
1964) -> bool {
1965 let Some(view) = view.filter(|view| view.enforced()) else {
1966 return true;
1967 };
1968 match scope_kind {
1969 "identity" => {
1970 view.allows_agent(ACTION_AGENT_MEMORY_READ, scope_key)
1971 && view.allows_agent(ACTION_AGENT_VIEW, scope_key)
1972 }
1973 "mob" => view.allows(ACTION_MOB_MEMORY_READ),
1974 "operator" => view.allows(ACTION_OPERATOR_MEMORY_READ),
1975 "realm" => view.allows(ACTION_AGENT_MEMORY_READ),
1976 _ => false,
1977 }
1978}
1979
1980fn memory_panel_record_json(
1983 record: &crate::memory::records::MemoryRecord,
1984 include_body: bool,
1985) -> Value {
1986 let mut value = serde_json::to_value(record).unwrap_or(Value::Null);
1987 if !include_body && let Some(object) = value.as_object_mut() {
1988 object.remove("body");
1989 object.insert("body_bytes".to_string(), json!(record.body.len()));
1990 }
1991 value
1992}
1993
1994async fn memory_panel_realms(
1997 store: &crate::memory::sqlite_store::SqliteAgentMemoryStore,
1998 params: &Value,
1999) -> Result<Vec<String>, crate::identity_first::agent_memory::AgentMemoryError> {
2000 match normalized_console_rpc_string_param(params, "realm").filter(|realm| !realm.is_empty()) {
2001 Some(realm) => Ok(vec![realm]),
2002 None => store.panel_realms().await,
2003 }
2004}
2005
2006async fn handle_memory_panel_records(
2007 store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2008 view: Option<&AccessView>,
2009 params: &Value,
2010 response_id: Value,
2011) -> Value {
2012 let Some(store) = store else {
2013 return memory_panel_unavailable(response_id);
2014 };
2015 let scope_kind = memory_panel_scope_param(params);
2016 if let Some(kind) = &scope_kind
2017 && !matches!(kind.as_str(), "identity" | "mob" | "operator" | "realm")
2018 {
2019 return invalid_params(
2020 response_id,
2021 format!("Invalid params: unknown scope '{kind}'"),
2022 );
2023 }
2024 let identity = normalized_console_rpc_string_param(params, "identity")
2025 .filter(|identity| !identity.is_empty());
2026 let scope_kind = scope_kind.or_else(|| identity.as_ref().map(|_| "identity".to_string()));
2027 let scope_key = normalized_console_rpc_string_param(params, "scope_key")
2028 .filter(|key| !key.is_empty())
2029 .or(identity);
2030 let status =
2031 normalized_console_rpc_string_param(params, "status").filter(|status| !status.is_empty());
2032 if let Some(status) = &status
2033 && !matches!(
2034 status.as_str(),
2035 "active" | "quarantined" | "superseded" | "tombstoned"
2036 )
2037 {
2038 return invalid_params(
2039 response_id,
2040 format!("Invalid params: unknown status '{status}'"),
2041 );
2042 }
2043 let limit =
2044 memory_panel_limit_param(params, MEMORY_PANEL_DEFAULT_LIMIT, MEMORY_PANEL_MAX_LIMIT);
2045 let cursor = normalized_console_rpc_string_param(params, "cursor")
2046 .filter(|cursor| !cursor.is_empty())
2047 .map(|raw| parse_memory_panel_cursor(&raw));
2048 let cursor = match cursor {
2049 Some(None) => {
2050 return invalid_params(response_id, "Invalid params: malformed cursor".to_string());
2051 }
2052 Some(Some(cursor)) => Some(cursor),
2053 None => None,
2054 };
2055 let realms = match memory_panel_realms(store, params).await {
2056 Ok(realms) => realms,
2057 Err(err) => return memory_panel_store_error(response_id, err),
2058 };
2059 let single_realm = realms.len() == 1;
2062 let mut records = Vec::new();
2063 let mut next_cursor = None;
2064 for realm in &realms {
2065 let page = match store
2066 .records_page(
2067 realm,
2068 scope_kind.as_deref(),
2069 scope_key.as_deref(),
2070 status.as_deref(),
2071 limit,
2072 if single_realm { cursor.clone() } else { None },
2073 )
2074 .await
2075 {
2076 Ok(page) => page,
2077 Err(err) => return memory_panel_store_error(response_id, err),
2078 };
2079 if single_realm {
2080 next_cursor = page.next_cursor.as_ref().map(encode_memory_panel_cursor);
2081 }
2082 records.extend(page.records);
2083 }
2084 records.retain(|record| memory_panel_record_visible(view, record));
2085 records.sort_by(|a, b| {
2086 b.updated_at_ms
2087 .cmp(&a.updated_at_ms)
2088 .then_with(|| b.id.cmp(&a.id))
2089 });
2090 records.truncate(limit);
2091 let rows: Vec<Value> = records
2092 .iter()
2093 .map(|record| memory_panel_record_json(record, false))
2094 .collect();
2095 response_value(
2096 response_id,
2097 Some(json!({
2098 "records": rows,
2099 "next_cursor": next_cursor,
2100 "realms": realms,
2101 })),
2102 None,
2103 )
2104}
2105
2106async fn handle_memory_panel_record(
2107 store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2108 view: Option<&AccessView>,
2109 params: &Value,
2110 response_id: Value,
2111) -> Value {
2112 let Some(store) = store else {
2113 return memory_panel_unavailable(response_id);
2114 };
2115 let Some(memory_id) = normalized_console_rpc_string_param(params, "memory_id")
2116 .filter(|memory_id| !memory_id.is_empty())
2117 else {
2118 return invalid_params(
2119 response_id,
2120 "Invalid params: memory_id is required".to_string(),
2121 );
2122 };
2123 let realms = match memory_panel_realms(store, params).await {
2124 Ok(realms) => realms,
2125 Err(err) => return memory_panel_store_error(response_id, err),
2126 };
2127 let mut found = None;
2128 for realm in &realms {
2129 match store.record_by_id(realm, &memory_id).await {
2130 Ok(Some(record)) => {
2131 found = Some((realm.clone(), record));
2132 break;
2133 }
2134 Ok(None) => {}
2135 Err(err) => return memory_panel_store_error(response_id, err),
2136 }
2137 }
2138 let Some((realm, record)) = found else {
2139 return response_value(
2140 response_id,
2141 None,
2142 Some(JsonRpcError {
2143 code: -32001,
2144 message: format!("unknown memory record '{memory_id}'"),
2145 data: None,
2146 }),
2147 );
2148 };
2149 if !memory_panel_record_visible(view, &record) {
2152 let action = if matches!(
2153 record.status,
2154 crate::memory::records::RecordStatus::Quarantined { .. }
2155 ) && view
2156 .is_some_and(|view| view.enforced() && !view.allows(ACTION_MEMORY_QUARANTINE_REVIEW))
2157 {
2158 ACTION_MEMORY_QUARANTINE_REVIEW
2159 } else {
2160 memory_panel_scope_action(&record.scope)
2161 };
2162 return response_value(
2163 response_id,
2164 None,
2165 Some(JsonRpcError {
2166 code: ACCESS_DENIED_RPC_CODE,
2167 message: format!("access denied: {action}"),
2168 data: Some(json!({
2169 "kind": "access_denied",
2170 "action": action,
2171 "resource": record.id,
2172 })),
2173 }),
2174 );
2175 }
2176 let chain = match store
2177 .supersede_chain(&realm, &memory_id, MEMORY_PANEL_CHAIN_MAX)
2178 .await
2179 {
2180 Ok(chain) => chain,
2181 Err(err) => return memory_panel_store_error(response_id, err),
2182 };
2183 let chain_rows: Vec<Value> = chain
2184 .iter()
2185 .filter(|link| memory_panel_record_visible(view, link))
2186 .map(|link| memory_panel_record_json(link, false))
2187 .collect();
2188 let injections = match store
2189 .injection_log_for_record(&realm, &memory_id, MEMORY_PANEL_INJECTIONS_MAX)
2190 .await
2191 {
2192 Ok(entries) => entries,
2193 Err(err) => return memory_panel_store_error(response_id, err),
2194 };
2195 response_value(
2196 response_id,
2197 Some(json!({
2198 "realm": realm,
2199 "record": memory_panel_record_json(&record, true),
2200 "chain": chain_rows,
2201 "injections": injections,
2202 })),
2203 None,
2204 )
2205}
2206
2207async fn handle_memory_panel_quarantine(
2208 store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2209 view: Option<&AccessView>,
2210 params: &Value,
2211 response_id: Value,
2212) -> Value {
2213 let Some(store) = store else {
2214 return memory_panel_unavailable(response_id);
2215 };
2216 let limit =
2217 memory_panel_limit_param(params, MEMORY_PANEL_DEFAULT_LIMIT, MEMORY_PANEL_MAX_LIMIT);
2218 let realms = match memory_panel_realms(store, params).await {
2219 Ok(realms) => realms,
2220 Err(err) => return memory_panel_store_error(response_id, err),
2221 };
2222 let mut records = Vec::new();
2223 let mut pending = Vec::new();
2224 for realm in &realms {
2225 match store.quarantined_records(realm, limit).await {
2226 Ok(rows) => records.extend(rows),
2227 Err(err) => return memory_panel_store_error(response_id, err),
2228 }
2229 match store.pending_promotions(realm).await {
2230 Ok(rows) => pending.extend(
2231 rows.into_iter()
2232 .filter(|promotion| {
2233 memory_panel_promotion_visible(
2234 view,
2235 &promotion.scope_kind,
2236 &promotion.scope_key,
2237 )
2238 })
2239 .map(|promotion| {
2240 json!({
2242 "realm": realm,
2243 "pending_id": promotion.pending_id,
2244 "record_id": promotion.record_id,
2245 "scope_kind": promotion.scope_kind,
2246 "scope_key": promotion.scope_key,
2247 "rationale": promotion.rationale,
2248 "status": promotion.status,
2249 "created_at_ms": promotion.created_at_ms,
2250 })
2251 }),
2252 ),
2253 Err(err) => return memory_panel_store_error(response_id, err),
2254 }
2255 }
2256 records.retain(|record| memory_panel_record_visible(view, record));
2260 records.sort_by_key(|record| std::cmp::Reverse(record.created_at_ms));
2261 records.truncate(limit);
2262 let rows: Vec<Value> = records
2265 .iter()
2266 .map(|record| memory_panel_record_json(record, false))
2267 .collect();
2268 response_value(
2269 response_id,
2270 Some(json!({
2271 "records": rows,
2272 "pending_promotions": pending,
2273 "realms": realms,
2274 })),
2275 None,
2276 )
2277}
2278
2279async fn handle_memory_panel_dreams(
2280 store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2281 params: &Value,
2282 response_id: Value,
2283) -> Value {
2284 let Some(store) = store else {
2285 return memory_panel_unavailable(response_id);
2286 };
2287 let limit =
2288 memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2289 let realms = match memory_panel_realms(store, params).await {
2290 Ok(realms) => realms,
2291 Err(err) => return memory_panel_store_error(response_id, err),
2292 };
2293 let mut runs = Vec::new();
2294 for realm in &realms {
2295 match store.dream_history(realm, limit).await {
2296 Ok(history) => runs.extend(history.into_iter().map(|run| (realm.clone(), run))),
2297 Err(err) => return memory_panel_store_error(response_id, err),
2298 }
2299 }
2300 runs.sort_by_key(|(_, run)| std::cmp::Reverse(run.last_op_at_ms));
2301 runs.truncate(limit);
2302 let rows: Vec<Value> = runs
2303 .iter()
2304 .map(|(realm, run)| {
2305 json!({
2306 "realm": realm,
2307 "run_id": run.run_id,
2308 "first_op_at_ms": run.first_op_at_ms,
2309 "last_op_at_ms": run.last_op_at_ms,
2310 "ops": run.ops,
2311 "op_kinds": run.op_kinds,
2312 "quarantined_ops": run.quarantined_ops,
2313 "memory_ids": run.memory_ids,
2314 "rationales": run.rationales,
2315 })
2316 })
2317 .collect();
2318 response_value(
2319 response_id,
2320 Some(json!({ "runs": rows, "realms": realms })),
2321 None,
2322 )
2323}
2324
2325async fn handle_memory_panel_dream_runs(
2329 store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2330 params: &Value,
2331 response_id: Value,
2332) -> Value {
2333 let Some(store) = store else {
2334 return memory_panel_unavailable(response_id);
2335 };
2336 let limit =
2337 memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2338 let realms = match memory_panel_realms(store, params).await {
2339 Ok(realms) => realms,
2340 Err(err) => return memory_panel_store_error(response_id, err),
2341 };
2342 let mut runs = Vec::new();
2343 for realm in &realms {
2344 match store.dream_runs(realm, limit).await {
2345 Ok(rows) => runs.extend(rows.into_iter().map(|run| (realm.clone(), run))),
2346 Err(err) => return memory_panel_store_error(response_id, err),
2347 }
2348 }
2349 runs.sort_by_key(|(_, run)| std::cmp::Reverse(run.completed_at_ms));
2350 runs.truncate(limit);
2351 let rows: Vec<Value> = runs
2352 .iter()
2353 .map(|(realm, run)| {
2354 let detail: Value =
2355 serde_json::from_str(&run.detail).unwrap_or_else(|_| json!(run.detail));
2356 json!({
2357 "realm": realm,
2358 "run_id": run.run_id,
2359 "partition": run.partition_label,
2360 "started_at_ms": run.started_at_ms,
2361 "completed_at_ms": run.completed_at_ms,
2362 "ops_committed": run.ops_committed,
2363 "detail": detail,
2364 })
2365 })
2366 .collect();
2367 response_value(
2368 response_id,
2369 Some(json!({ "runs": rows, "realms": realms })),
2370 None,
2371 )
2372}
2373
2374async fn handle_memory_panel_audit_verdicts(
2377 store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2378 params: &Value,
2379 response_id: Value,
2380) -> Value {
2381 let Some(store) = store else {
2382 return memory_panel_unavailable(response_id);
2383 };
2384 let limit =
2385 memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2386 let realms = match memory_panel_realms(store, params).await {
2387 Ok(realms) => realms,
2388 Err(err) => return memory_panel_store_error(response_id, err),
2389 };
2390 let mut verdicts = Vec::new();
2391 for realm in &realms {
2392 match store.open_dream_audit_verdicts(realm, limit).await {
2393 Ok(rows) => verdicts.extend(rows.into_iter().map(|row| (realm.clone(), row))),
2394 Err(err) => return memory_panel_store_error(response_id, err),
2395 }
2396 }
2397 verdicts.sort_by_key(|(_, row)| std::cmp::Reverse(row.created_at_ms));
2398 verdicts.truncate(limit);
2399 let rows: Vec<Value> = verdicts
2400 .iter()
2401 .map(|(realm, row)| {
2402 json!({
2403 "realm": realm,
2404 "run_id": row.run_id,
2405 "record_id": row.record_id,
2406 "verdict": row.verdict,
2407 "rationale": row.rationale,
2408 "created_at_ms": row.created_at_ms,
2409 })
2410 })
2411 .collect();
2412 response_value(
2413 response_id,
2414 Some(json!({ "verdicts": rows, "realms": realms })),
2415 None,
2416 )
2417}
2418
2419async fn handle_memory_panel_overview(
2423 store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2424 params: &Value,
2425 response_id: Value,
2426) -> Value {
2427 let Some(store) = store else {
2428 return memory_panel_unavailable(response_id);
2429 };
2430 let realms = match memory_panel_realms(store, params).await {
2431 Ok(realms) => realms,
2432 Err(err) => return memory_panel_store_error(response_id, err),
2433 };
2434 let (floor_records, floor_bytes) = store.scope_floors();
2435 let mut scopes = Vec::new();
2436 for realm in &realms {
2437 match store.scope_overview(realm).await {
2438 Ok(rows) => scopes.extend(rows.into_iter().map(|row| {
2439 json!({
2440 "realm": realm,
2441 "scope_kind": row.scope.kind_str(),
2442 "scope_key": row.scope.key(),
2443 "active": row.active,
2444 "quarantined": row.quarantined,
2445 "superseded": row.superseded,
2446 "tombstoned": row.tombstoned,
2447 "body_bytes": row.body_bytes,
2448 "floor_pressure": row.active as usize >= floor_records
2449 || row.body_bytes as usize >= floor_bytes,
2450 })
2451 })),
2452 Err(err) => return memory_panel_store_error(response_id, err),
2453 }
2454 }
2455 response_value(
2456 response_id,
2457 Some(json!({
2458 "scopes": scopes,
2459 "realms": realms,
2460 "floors": { "records": floor_records, "bytes": floor_bytes },
2461 })),
2462 None,
2463 )
2464}
2465
2466async fn handle_memory_panel_proposals(
2470 store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2471 params: &Value,
2472 response_id: Value,
2473) -> Value {
2474 let Some(store) = store else {
2475 return memory_panel_unavailable(response_id);
2476 };
2477 let limit =
2478 memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2479 let realms = match memory_panel_realms(store, params).await {
2480 Ok(realms) => realms,
2481 Err(err) => return memory_panel_store_error(response_id, err),
2482 };
2483 let mut proposals = Vec::new();
2484 for realm in &realms {
2485 match store.pending_proposals(realm, limit).await {
2486 Ok(rows) => proposals.extend(rows.into_iter().map(|row| {
2487 json!({
2488 "realm": realm,
2489 "proposal_id": row.proposal_id,
2490 "scope_kind": row.scope.kind_str(),
2491 "scope_key": row.scope.key(),
2492 "title": row.record.title,
2493 "kind": row.record.kind.as_str(),
2494 "author": format!("{:?}", row.author),
2495 "status": row.status,
2496 "created_at_ms": row.created_at_ms,
2497 "tainted": row.taint.is_some(),
2498 })
2499 })),
2500 Err(err) => return memory_panel_store_error(response_id, err),
2501 }
2502 }
2503 proposals.truncate(limit);
2504 response_value(
2505 response_id,
2506 Some(json!({ "proposals": proposals, "realms": realms })),
2507 None,
2508 )
2509}
2510
2511async fn handle_memory_panel_injections(
2514 store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2515 params: &Value,
2516 response_id: Value,
2517) -> Value {
2518 let Some(store) = store else {
2519 return memory_panel_unavailable(response_id);
2520 };
2521 let limit =
2522 memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2523 let realms = match memory_panel_realms(store, params).await {
2524 Ok(realms) => realms,
2525 Err(err) => return memory_panel_store_error(response_id, err),
2526 };
2527 let mut injections = Vec::new();
2528 for realm in &realms {
2529 match store.injection_log(realm, limit).await {
2530 Ok(rows) => injections.extend(rows.into_iter().map(|row| {
2531 json!({
2532 "realm": realm,
2533 "record_id": row.record_id,
2534 "identity": row.identity,
2535 "session_key": row.session_key,
2536 "surface": row.surface.as_str(),
2537 "at_ms": row.at_ms,
2538 })
2539 })),
2540 Err(err) => return memory_panel_store_error(response_id, err),
2541 }
2542 }
2543 injections.sort_by_key(|row| std::cmp::Reverse(row["at_ms"].as_u64().unwrap_or(0)));
2544 injections.truncate(limit);
2545 response_value(
2546 response_id,
2547 Some(json!({ "injections": injections, "realms": realms })),
2548 None,
2549 )
2550}
2551
2552async fn handle_memory_panel_harvests(
2554 store: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
2555 params: &Value,
2556 response_id: Value,
2557) -> Value {
2558 let Some(store) = store else {
2559 return memory_panel_unavailable(response_id);
2560 };
2561 let limit =
2562 memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2563 let realms = match memory_panel_realms(store, params).await {
2564 Ok(realms) => realms,
2565 Err(err) => return memory_panel_store_error(response_id, err),
2566 };
2567 let mut harvests = Vec::new();
2568 for realm in &realms {
2569 match store.pending_harvests(realm, limit).await {
2570 Ok(rows) => harvests.extend(rows.into_iter().map(|row| {
2571 json!({
2572 "realm": realm,
2573 "identity": row.identity,
2574 "session_key": row.session_key,
2575 "cause": row.cause,
2576 "retired_at_ms": row.retired_at_ms,
2577 })
2578 })),
2579 Err(err) => return memory_panel_store_error(response_id, err),
2580 }
2581 }
2582 harvests.truncate(limit);
2583 response_value(
2584 response_id,
2585 Some(json!({ "harvests": harvests, "realms": realms })),
2586 None,
2587 )
2588}
2589
2590fn access_status_result(access: Option<&AccessController>, view: Option<&AccessView>) -> Value {
2591 match (access, view) {
2592 (Some(controller), Some(view)) => {
2593 let (_, revision) = controller.snapshot();
2594 json!({
2595 "available": true,
2596 "enabled": view.enforced(),
2597 "revision": revision,
2598 "subject": view.subject(),
2599 "groups": view.groups().iter().collect::<Vec<_>>(),
2600 "is_admin": view.is_admin(),
2601 "can_administer": view.can_administer(),
2602 "actions": ACCESS_ACTIONS,
2603 })
2604 }
2605 _ => json!({
2606 "available": false,
2607 "enabled": false,
2608 "actions": ACCESS_ACTIONS,
2609 }),
2610 }
2611}
2612
2613fn handle_access_admin_rpc(
2616 access: Option<&AccessController>,
2617 view: Option<&AccessView>,
2618 request: &JsonRpcRequest,
2619) -> Option<Value> {
2620 if !request.method.starts_with("mobkit/access/") {
2621 return None;
2622 }
2623 let response_id = request.id.clone().unwrap_or(Value::Null);
2624 let controller = match request.method.as_str() {
2625 "mobkit/access/status" => {
2626 return Some(response_value(
2627 response_id,
2628 Some(access_status_result(access, view)),
2629 None,
2630 ));
2631 }
2632 _ => {
2633 let Some(controller) = access else {
2634 return Some(access_unavailable_rpc_error(response_id));
2635 };
2636 if !view.is_some_and(AccessView::can_administer) {
2637 return Some(access_denied_rpc_error(
2638 response_id,
2639 "access denied: access.admin",
2640 ));
2641 }
2642 controller
2643 }
2644 };
2645 let result = match request.method.as_str() {
2646 "mobkit/access/get" => {
2647 let (config, revision) = controller.snapshot();
2648 Ok(json!({ "config": &*config, "revision": revision }))
2649 }
2650 "mobkit/access/set" => {
2651 match serde_json::from_value(request.params.get("config").cloned().unwrap_or_default())
2652 {
2653 Ok(config) => controller
2654 .replace_config(config)
2655 .map(|revision| json!({ "revision": revision })),
2656 Err(err) => {
2657 return Some(invalid_params(
2658 response_id,
2659 format!("invalid access config: {err}"),
2660 ));
2661 }
2662 }
2663 }
2664 "mobkit/access/rules/upsert" => {
2665 match serde_json::from_value::<AccessRule>(
2666 request.params.get("rule").cloned().unwrap_or_default(),
2667 ) {
2668 Ok(rule) => controller
2669 .upsert_rule(rule)
2670 .map(|revision| json!({ "revision": revision })),
2671 Err(err) => {
2672 return Some(invalid_params(
2673 response_id,
2674 format!("invalid access rule: {err}"),
2675 ));
2676 }
2677 }
2678 }
2679 "mobkit/access/rules/delete" => {
2680 let Some(rule_id) = request.params.get("id").and_then(Value::as_str) else {
2681 return Some(invalid_params(response_id, "id required"));
2682 };
2683 controller
2684 .delete_rule(rule_id)
2685 .map(|revision| json!({ "revision": revision }))
2686 }
2687 "mobkit/access/groups/set" => {
2688 let Some(name) = request.params.get("name").and_then(Value::as_str) else {
2689 return Some(invalid_params(response_id, "name required"));
2690 };
2691 match serde_json::from_value::<AccessGroup>(
2692 request.params.get("group").cloned().unwrap_or_default(),
2693 ) {
2694 Ok(group) => controller
2695 .set_group(name, group)
2696 .map(|revision| json!({ "revision": revision })),
2697 Err(err) => {
2698 return Some(invalid_params(
2699 response_id,
2700 format!("invalid access group: {err}"),
2701 ));
2702 }
2703 }
2704 }
2705 "mobkit/access/groups/delete" => {
2706 let Some(name) = request.params.get("name").and_then(Value::as_str) else {
2707 return Some(invalid_params(response_id, "name required"));
2708 };
2709 controller
2710 .delete_group(name)
2711 .map(|revision| json!({ "revision": revision }))
2712 }
2713 "mobkit/access/enable" => {
2714 let Some(enabled) = request.params.get("enabled").and_then(Value::as_bool) else {
2715 return Some(invalid_params(response_id, "enabled (bool) required"));
2716 };
2717 controller
2718 .set_enabled(enabled)
2719 .map(|revision| json!({ "revision": revision }))
2720 }
2721 "mobkit/access/preview" => {
2722 let subject = request.params.get("subject").and_then(Value::as_str);
2723 let Some(action) = request.params.get("action").and_then(Value::as_str) else {
2724 return Some(invalid_params(response_id, "action required"));
2725 };
2726 let identity = request.params.get("identity").and_then(Value::as_str);
2727 let preview_view = controller.view_for_subject(subject);
2728 let decision = match identity {
2729 Some(identity) => preview_view.decide_agent(action, identity),
2730 None => preview_view.decide(action, &AccessResource::none()),
2731 };
2732 Ok(json!({
2733 "subject": subject,
2734 "action": action,
2735 "identity": identity,
2736 "allowed": decision.is_allow(),
2737 "reason": decision.reason(),
2738 "groups": preview_view.groups().iter().collect::<Vec<_>>(),
2739 "is_admin": preview_view.is_admin(),
2740 }))
2741 }
2742 _ => {
2743 return Some(response_value(
2744 response_id,
2745 None,
2746 Some(JsonRpcError {
2747 code: -32601,
2748 message: "Method not found".to_string(),
2749 data: None,
2750 }),
2751 ));
2752 }
2753 };
2754 Some(match result {
2755 Ok(value) => response_value(response_id, Some(value), None),
2756 Err(err) => access_config_rpc_error(response_id, err),
2757 })
2758}
2759
2760fn cursor_is_after(candidate: &ConsoleCursor, current: &ConsoleCursor) -> bool {
2761 match (candidate.seq(), current.seq()) {
2762 (Some(candidate), Some(current)) => candidate > current,
2763 _ => candidate > current,
2764 }
2765}
2766
2767fn sse_event_from_timeline_event(event: &ConsoleTimelineEvent) -> Option<Event> {
2768 let (event_name, id) = match event {
2769 ConsoleTimelineEvent::SnapshotStarted { .. } => ("snapshot_started", None),
2770 ConsoleTimelineEvent::ConsoleFrame { frame } => (
2771 if frame.kind == "frame_updated" {
2772 "frame_updated"
2773 } else {
2774 "console_frame"
2775 },
2776 Some(frame.cursor.to_string()),
2777 ),
2778 ConsoleTimelineEvent::FrameUpdated { frame } => {
2779 ("frame_updated", Some(frame.cursor.to_string()))
2780 }
2781 ConsoleTimelineEvent::SnapshotComplete { cursor } => (
2782 "snapshot_complete",
2783 cursor.as_ref().map(ToString::to_string),
2784 ),
2785 ConsoleTimelineEvent::ReplayUnavailable { .. } => ("replay_unavailable", None),
2786 };
2787 let data = match serde_json::to_string(event) {
2788 Ok(value) => value,
2789 Err(_) => return None,
2790 };
2791 let mut sse = Event::default().event(event_name).data(data);
2792 if let Some(id) = id {
2793 sse = sse.id(id);
2794 }
2795 Some(sse)
2796}
2797
2798pub async fn console_rpc_multipart_handler(
2799 State(state): State<ConsoleJsonState>,
2800 headers: HeaderMap,
2801 uri: Uri,
2802 mut multipart: Multipart,
2803) -> impl IntoResponse {
2804 let auth_context = match console_request_auth_context(&state, &headers, &uri) {
2805 Some(context) => context,
2806 None => {
2807 return (
2808 StatusCode::UNAUTHORIZED,
2809 Json::<Value>(serde_json::json!({
2810 "jsonrpc": JSONRPC_VERSION,
2811 "id": Value::Null,
2812 "error": {
2813 "code": -32600,
2814 "message": "unauthorized: console rpc requires a valid auth token",
2815 }
2816 })),
2817 );
2818 }
2819 };
2820
2821 let mut payload: Option<String> = None;
2822 let mut files: std::collections::BTreeMap<String, MultipartImageUpload> =
2823 std::collections::BTreeMap::new();
2824
2825 while let Some(mut field) = match multipart.next_field().await {
2826 Ok(field) => field,
2827 Err(err) => {
2828 return (
2829 StatusCode::BAD_REQUEST,
2830 Json::<Value>(json_rpc_error_value(
2831 Value::Null,
2832 -32602,
2833 format!("invalid multipart body: {err}"),
2834 )),
2835 );
2836 }
2837 } {
2838 let name = field.name().unwrap_or("").to_string();
2839 if name == "payload" {
2840 if payload.is_some() {
2841 return (
2842 StatusCode::BAD_REQUEST,
2843 Json::<Value>(json_rpc_error_value(
2844 Value::Null,
2845 -32602,
2846 "duplicate payload part",
2847 )),
2848 );
2849 }
2850 payload = match field.text().await {
2851 Ok(text) => Some(text),
2852 Err(err) => {
2853 return (
2854 StatusCode::BAD_REQUEST,
2855 Json::<Value>(json_rpc_error_value(
2856 Value::Null,
2857 -32602,
2858 format!("invalid payload part: {err}"),
2859 )),
2860 );
2861 }
2862 };
2863 continue;
2864 }
2865
2866 let Some(upload_id) = name.strip_prefix("file:").filter(|id| !id.is_empty()) else {
2867 return (
2868 StatusCode::BAD_REQUEST,
2869 Json::<Value>(json_rpc_error_value(
2870 Value::Null,
2871 -32602,
2872 format!("unexpected multipart field: {name}"),
2873 )),
2874 );
2875 };
2876 if files.len() >= MAX_MULTIPART_IMAGES {
2877 return (
2878 StatusCode::BAD_REQUEST,
2879 Json::<Value>(json_rpc_error_value(
2880 Value::Null,
2881 -32602,
2882 format!("too many image attachments; max {MAX_MULTIPART_IMAGES}"),
2883 )),
2884 );
2885 }
2886 if files.contains_key(upload_id) {
2887 return (
2888 StatusCode::BAD_REQUEST,
2889 Json::<Value>(json_rpc_error_value(
2890 Value::Null,
2891 -32602,
2892 format!("duplicate file part for upload_id {upload_id}"),
2893 )),
2894 );
2895 }
2896 let media_type = field
2897 .content_type()
2898 .map(str::to_string)
2899 .unwrap_or_else(|| "application/octet-stream".to_string());
2900 if !is_allowed_image_media_type(&media_type) {
2901 return (
2902 StatusCode::BAD_REQUEST,
2903 Json::<Value>(json_rpc_error_value(
2904 Value::Null,
2905 -32602,
2906 format!("unsupported image media type: {media_type}"),
2907 )),
2908 );
2909 }
2910 let mut bytes = bytes::BytesMut::new();
2911 loop {
2912 let chunk = match field.chunk().await {
2913 Ok(chunk) => chunk,
2914 Err(err) => {
2915 return (
2916 StatusCode::BAD_REQUEST,
2917 Json::<Value>(json_rpc_error_value(
2918 Value::Null,
2919 -32602,
2920 format!("invalid file part {upload_id}: {err}"),
2921 )),
2922 );
2923 }
2924 };
2925 let Some(chunk) = chunk else {
2926 break;
2927 };
2928 if bytes.len() + chunk.len() > MAX_MULTIPART_IMAGE_BYTES {
2929 return (
2930 StatusCode::BAD_REQUEST,
2931 Json::<Value>(json_rpc_error_value(
2932 Value::Null,
2933 -32602,
2934 format!("image attachment {upload_id} exceeds 25 MiB"),
2935 )),
2936 );
2937 }
2938 bytes.extend_from_slice(&chunk);
2939 }
2940 files.insert(
2941 upload_id.to_string(),
2942 MultipartImageUpload {
2943 media_type,
2944 bytes: bytes.freeze(),
2945 },
2946 );
2947 }
2948
2949 let payload = match payload {
2950 Some(payload) => payload,
2951 None => {
2952 return (
2953 StatusCode::BAD_REQUEST,
2954 Json::<Value>(json_rpc_error_value(
2955 Value::Null,
2956 -32602,
2957 "payload part required",
2958 )),
2959 );
2960 }
2961 };
2962 let mut parsed_request = match serde_json::from_str::<JsonRpcRequest>(&payload) {
2963 Ok(req) => req,
2964 Err(err) => {
2965 return (
2966 StatusCode::OK,
2967 Json::<Value>(json_rpc_error_value(
2968 Value::Null,
2969 -32600,
2970 format!("Invalid Request: {err}"),
2971 )),
2972 );
2973 }
2974 };
2975 let response_id = parsed_request.id.clone().unwrap_or(Value::Null);
2976 if state.decisions.console.read_only && is_console_mutating_rpc_method(&parsed_request.method) {
2977 return (
2978 StatusCode::OK,
2979 Json::<Value>(console_read_only_rpc_error(response_id)),
2980 );
2981 }
2982 match parsed_request.method.as_str() {
2983 "mobkit/console/send" => {
2984 let Some(aggregator) = &state.console_aggregator else {
2985 return (
2986 StatusCode::OK,
2987 Json::<Value>(invalid_params(
2988 response_id,
2989 "mobkit/console/send multipart requires a console aggregator",
2990 )),
2991 );
2992 };
2993 let Some(identity) = parsed_request
2994 .params
2995 .get("identity")
2996 .and_then(Value::as_str)
2997 else {
2998 return (
2999 StatusCode::OK,
3000 Json::<Value>(invalid_params(response_id, "identity required")),
3001 );
3002 };
3003 if let Some(runtime) = &state.runtime
3011 && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
3012 {
3013 prime_access_cache_from_runtime(runtime, controller).await;
3014 }
3015 if let Some(violation) = console_rpc_access_violation(
3016 auth_context.access_view.as_ref(),
3017 "mobkit/console/send",
3018 &parsed_request.params,
3019 ) {
3020 return (
3021 StatusCode::OK,
3022 Json::<Value>(response_value(response_id, None, Some(violation))),
3023 );
3024 }
3025 let binary_blob_store =
3026 match Box::pin(aggregator.binary_blob_store_for_identity(identity)).await {
3027 Ok(Some(store)) => store,
3028 Ok(None) => {
3029 return (
3030 StatusCode::OK,
3031 Json::<Value>(invalid_params(
3032 response_id,
3033 "binary blob store unavailable for identity",
3034 )),
3035 );
3036 }
3037 Err(err) => {
3038 return (
3039 StatusCode::OK,
3040 Json::<Value>(console_send_rpc_error(response_id, err)),
3041 );
3042 }
3043 };
3044 if let Err(message) = externalize_image_upload_placeholders(
3045 &mut parsed_request.params,
3046 files,
3047 binary_blob_store,
3048 )
3049 .await
3050 {
3051 return (
3052 StatusCode::OK,
3053 Json::<Value>(invalid_params(response_id, message)),
3054 );
3055 }
3056 }
3057 "mobkit/blob/upload" => {
3058 let Some(runtime) = &state.runtime else {
3059 return (
3060 StatusCode::NOT_FOUND,
3061 Json::<Value>(json_rpc_error_value(
3062 response_id,
3063 -32600,
3064 "mobkit/blob/upload multipart requires a unified runtime",
3065 )),
3066 );
3067 };
3068 let Some(binary_blob_store) = runtime.binary_blob_store() else {
3069 return (
3070 StatusCode::INTERNAL_SERVER_ERROR,
3071 Json::<Value>(json_rpc_error_value(
3072 response_id,
3073 -32000,
3074 "binary blob store unavailable",
3075 )),
3076 );
3077 };
3078 let result = match externalize_single_image_upload(
3079 &parsed_request.params,
3080 files,
3081 binary_blob_store,
3082 )
3083 .await
3084 {
3085 Ok(result) => result,
3086 Err(message) => {
3087 return (
3088 StatusCode::OK,
3089 Json::<Value>(invalid_params(response_id, message)),
3090 );
3091 }
3092 };
3093 return (
3094 StatusCode::OK,
3095 Json::<Value>(response_value(response_id, Some(result), None)),
3096 );
3097 }
3098 _ => {
3099 return (
3100 StatusCode::OK,
3101 Json::<Value>(invalid_params(
3102 response_id,
3103 "multipart RPC supports mobkit/console/send and mobkit/blob/upload only",
3104 )),
3105 );
3106 }
3107 }
3108 let response_value =
3109 if parsed_request.method == "mobkit/console/send" && state.runtime.is_none() {
3110 Box::pin(handle_console_aggregator_rpc(
3111 state.console_aggregator.clone(),
3112 parsed_request,
3113 true,
3114 state.decisions.console.read_only,
3115 state.access.as_ref(),
3116 auth_context.access_view.as_ref(),
3117 ))
3118 .await
3119 } else {
3120 let Some(runtime) = &state.runtime else {
3121 return (
3122 StatusCode::NOT_FOUND,
3123 Json::<Value>(json_rpc_error_value(
3124 response_id,
3125 -32600,
3126 "console rpc multipart requires a unified runtime",
3127 )),
3128 );
3129 };
3130 Box::pin(handle_console_runtime_rpc_with_visibility(
3131 runtime,
3132 state.module_runtime.clone(),
3133 state.contact_directory.as_ref(),
3134 state.gateway_peer_keys.as_ref(),
3135 state.console_events.clone(),
3136 state.console_aggregator.clone(),
3137 state.identity_runtime.clone(),
3138 state.metadata_table.clone(),
3139 state.mob_events.clone(),
3140 state.visibility_policy.as_ref(),
3141 parsed_request,
3142 true,
3143 state.decisions.console.read_only,
3144 auth_context.principal.as_deref(),
3145 state.access.as_ref(),
3146 auth_context.access_view.as_ref(),
3147 state.memory_panel.as_ref(),
3148 state.identity_roster.clone(),
3149 state.workgraph.as_ref(),
3150 state.topology.as_ref(),
3151 ))
3152 .await
3153 };
3154 (StatusCode::OK, Json::<Value>(response_value))
3155}
3156
3157pub async fn blob_get_handler(
3158 State(state): State<ConsoleJsonState>,
3159 headers: HeaderMap,
3160 uri: Uri,
3161 AxumPath(blob_id): AxumPath<String>,
3162) -> impl IntoResponse {
3163 if !console_request_authorized(&state, &headers, &uri) {
3164 return (
3165 StatusCode::UNAUTHORIZED,
3166 Json::<Value>(serde_json::json!({ "error": "unauthorized" })),
3167 )
3168 .into_response();
3169 }
3170 if !is_valid_blob_id_value(&blob_id) {
3171 return (
3172 StatusCode::BAD_REQUEST,
3173 Json::<Value>(serde_json::json!({ "error": "invalid_blob_id" })),
3174 )
3175 .into_response();
3176 }
3177 let blob_id = meerkat_core::BlobId::from(blob_id.as_str());
3178 let mut stores: Vec<std::sync::Arc<dyn BinaryBlobStore>> = Vec::new();
3179 if let Some(runtime) = &state.runtime
3180 && let Some(store) = runtime.binary_blob_store()
3181 {
3182 stores.push(store);
3183 }
3184 if let Some(aggregator) = &state.console_aggregator {
3185 stores.extend(aggregator.binary_blob_stores());
3186 }
3187 if stores.is_empty() {
3188 return (
3189 StatusCode::NOT_FOUND,
3190 Json::<Value>(serde_json::json!({ "error": "blob_store_unavailable" })),
3191 )
3192 .into_response();
3193 }
3194 for store in stores {
3195 match store.get_bytes(&blob_id).await {
3196 Ok(payload) => return blob_payload_response(payload),
3197 Err(meerkat_core::BlobStoreError::NotFound(_)) => continue,
3198 Err(err) => {
3199 return (
3200 StatusCode::INTERNAL_SERVER_ERROR,
3201 Json::<Value>(serde_json::json!({ "error": err.to_string() })),
3202 )
3203 .into_response();
3204 }
3205 }
3206 }
3207 (
3208 StatusCode::NOT_FOUND,
3209 Json::<Value>(serde_json::json!({ "error": "blob_not_found" })),
3210 )
3211 .into_response()
3212}
3213
3214fn blob_payload_response(payload: BinaryBlobPayload) -> axum::response::Response {
3215 let mut response_headers = HeaderMap::new();
3216 let content_type = HeaderValue::from_str(&payload.media_type)
3217 .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
3218 response_headers.insert(header::CONTENT_TYPE, content_type);
3219 if let Ok(content_length) = HeaderValue::from_str(&payload.size.to_string()) {
3220 response_headers.insert(header::CONTENT_LENGTH, content_length);
3221 }
3222 response_headers.insert(
3223 header::CACHE_CONTROL,
3224 HeaderValue::from_static("private, max-age=31536000, immutable"),
3225 );
3226 (StatusCode::OK, response_headers, payload.data).into_response()
3227}
3228
3229fn console_request_authorized(state: &ConsoleJsonState, headers: &HeaderMap, uri: &Uri) -> bool {
3230 console_request_auth_context(state, headers, uri).is_some()
3231}
3232
3233fn console_request_auth_context(
3234 state: &ConsoleJsonState,
3235 headers: &HeaderMap,
3236 uri: &Uri,
3237) -> Option<ConsoleHttpAuthContext> {
3238 if !state.decisions.console.require_app_auth {
3239 let principal = state.access.as_ref().and_then(|_| {
3242 let token = console_request_token(headers, uri)?;
3243 resolve_authorized_console_auth_from_token(&state.decisions, &token)
3244 .map(|auth| auth.email)
3245 });
3246 return Some(ConsoleHttpAuthContext {
3247 access_view: state
3248 .access
3249 .as_ref()
3250 .map(|controller| controller.view_for_subject(principal.as_deref())),
3251 principal,
3252 });
3253 }
3254 let token = console_request_token(headers, uri)?;
3255 let auth = resolve_authorized_console_auth_from_token(&state.decisions, &token)?;
3256 Some(ConsoleHttpAuthContext {
3257 access_view: state
3258 .access
3259 .as_ref()
3260 .map(|controller| controller.view_for_subject(Some(auth.email.as_str()))),
3261 principal: Some(auth.email),
3262 })
3263}
3264
3265fn console_request_token(headers: &HeaderMap, uri: &Uri) -> Option<String> {
3266 let bearer_token = headers
3267 .get(header::AUTHORIZATION)
3268 .and_then(|v| v.to_str().ok())
3269 .and_then(extract_bearer_token_from_header)
3270 .map(String::from);
3271 let query_token = uri.query().and_then(|q| {
3274 form_urlencoded::parse(q.as_bytes())
3275 .find(|(key, _)| key == "auth_token")
3276 .map(|(_, value)| value.into_owned())
3277 });
3278 bearer_token.or(query_token)
3279}
3280
3281#[derive(Debug)]
3282struct MultipartImageUpload {
3283 media_type: String,
3284 bytes: bytes::Bytes,
3285}
3286
3287fn json_rpc_error_value(id: Value, code: i64, message: impl Into<String>) -> Value {
3288 serde_json::json!({
3289 "jsonrpc": JSONRPC_VERSION,
3290 "id": id,
3291 "error": {
3292 "code": code,
3293 "message": message.into(),
3294 }
3295 })
3296}
3297
3298fn is_allowed_image_media_type(media_type: &str) -> bool {
3299 matches!(
3300 media_type,
3301 "image/png" | "image/jpeg" | "image/webp" | "image/gif"
3302 )
3303}
3304
3305fn image_upload_part_name<'a>(
3306 object: &'a serde_json::Map<String, Value>,
3307 context: &str,
3308) -> Result<&'a str, String> {
3309 object
3310 .get("upload_id")
3311 .or_else(|| object.get("part_name"))
3312 .and_then(Value::as_str)
3313 .map(str::trim)
3314 .filter(|value| !value.is_empty())
3315 .ok_or_else(|| format!("{context}.upload_id or {context}.part_name is required"))
3316}
3317
3318async fn externalize_image_upload_placeholders(
3319 params: &mut Value,
3320 files: std::collections::BTreeMap<String, MultipartImageUpload>,
3321 blob_store: std::sync::Arc<dyn crate::blob_store::BinaryBlobStore>,
3322) -> Result<(), String> {
3323 let Some(content) = params.get_mut("content") else {
3324 return Err("multipart payload params.content is required".to_string());
3325 };
3326 let mut placeholders = std::collections::BTreeMap::<String, String>::new();
3327 collect_image_upload_placeholders(content, &mut placeholders)?;
3328 if placeholders.is_empty() {
3329 return Err(
3330 "multipart payload must contain at least one image_upload placeholder".to_string(),
3331 );
3332 }
3333 if placeholders.len() > MAX_MULTIPART_IMAGES {
3334 return Err(format!(
3335 "too many image_upload placeholders; max {MAX_MULTIPART_IMAGES}"
3336 ));
3337 }
3338 for upload_id in files.keys() {
3339 if !placeholders.contains_key(upload_id) {
3340 return Err(format!(
3341 "file part has no matching image_upload placeholder: {upload_id}"
3342 ));
3343 }
3344 }
3345 for upload_id in placeholders.keys() {
3346 if !files.contains_key(upload_id) {
3347 return Err(format!(
3348 "image_upload placeholder missing file part: {upload_id}"
3349 ));
3350 }
3351 }
3352
3353 let mut refs = std::collections::BTreeMap::<String, Value>::new();
3354 for (upload_id, file) in files {
3355 let declared_media_type = placeholders
3356 .get(&upload_id)
3357 .cloned()
3358 .unwrap_or_else(|| file.media_type.clone());
3359 if !is_allowed_image_media_type(&declared_media_type) {
3360 return Err(format!(
3361 "unsupported image media type in placeholder {upload_id}: {declared_media_type}"
3362 ));
3363 }
3364 if declared_media_type != file.media_type {
3365 return Err(format!(
3366 "media type mismatch for {upload_id}: placeholder {declared_media_type}, file {}",
3367 file.media_type
3368 ));
3369 }
3370 let blob_ref = blob_store
3371 .put_bytes(&file.media_type, file.bytes)
3372 .await
3373 .map_err(|err| format!("failed to store image {upload_id}: {err}"))?;
3374 refs.insert(
3375 upload_id,
3376 serde_json::json!({
3377 "type": "image",
3378 "media_type": blob_ref.media_type,
3379 "source": "blob",
3380 "blob_id": blob_ref.blob_id,
3381 }),
3382 );
3383 }
3384 replace_image_upload_placeholders(content, &refs)?;
3385 if let Some(object) = params.as_object_mut() {
3386 object.remove("message");
3387 }
3388 Ok(())
3389}
3390
3391async fn externalize_single_image_upload(
3392 params: &Value,
3393 files: std::collections::BTreeMap<String, MultipartImageUpload>,
3394 blob_store: std::sync::Arc<dyn crate::blob_store::BinaryBlobStore>,
3395) -> Result<Value, String> {
3396 let upload = params.get("upload").unwrap_or(params);
3397 if upload
3398 .get("type")
3399 .and_then(Value::as_str)
3400 .is_some_and(|kind| kind != "image_upload")
3401 {
3402 return Err("upload.type must be image_upload".to_string());
3403 }
3404 let upload_object = upload
3405 .as_object()
3406 .ok_or_else(|| "upload must be an object".to_string())?;
3407 let upload_id = image_upload_part_name(upload_object, "upload")?;
3408 let Some(file) = files.get(upload_id) else {
3409 return Err(format!(
3410 "image_upload placeholder missing file part: {upload_id}"
3411 ));
3412 };
3413 if files.len() != 1 {
3414 return Err("mobkit/blob/upload accepts exactly one file part".to_string());
3415 }
3416 let declared_media_type = upload
3417 .get("media_type")
3418 .and_then(Value::as_str)
3419 .unwrap_or(file.media_type.as_str());
3420 if !is_allowed_image_media_type(declared_media_type) {
3421 return Err(format!(
3422 "unsupported image media type in upload {upload_id}: {declared_media_type}"
3423 ));
3424 }
3425 if declared_media_type != file.media_type {
3426 return Err(format!(
3427 "media type mismatch for {upload_id}: placeholder {declared_media_type}, file {}",
3428 file.media_type
3429 ));
3430 }
3431 let size = file.bytes.len() as u64;
3432 let blob_ref = blob_store
3433 .put_bytes(&file.media_type, file.bytes.clone())
3434 .await
3435 .map_err(|err| format!("failed to store image {upload_id}: {err}"))?;
3436 Ok(json!({
3437 "blob_id": blob_ref.blob_id,
3438 "media_type": blob_ref.media_type,
3439 "size": size,
3440 }))
3441}
3442
3443fn collect_image_upload_placeholders(
3444 value: &Value,
3445 placeholders: &mut std::collections::BTreeMap<String, String>,
3446) -> Result<(), String> {
3447 match value {
3448 Value::Array(items) => {
3449 for item in items {
3450 collect_image_upload_placeholders(item, placeholders)?;
3451 }
3452 }
3453 Value::Object(object) => {
3454 if object.get("type").and_then(Value::as_str) == Some("image_upload") {
3455 let upload_id = image_upload_part_name(object, "image_upload")?;
3456 let media_type = object
3457 .get("media_type")
3458 .and_then(Value::as_str)
3459 .map(str::trim)
3460 .filter(|value| !value.is_empty())
3461 .ok_or_else(|| format!("image_upload {upload_id} requires media_type"))?;
3462 if placeholders
3463 .insert(upload_id.to_string(), media_type.to_string())
3464 .is_some()
3465 {
3466 return Err(format!("duplicate image_upload placeholder: {upload_id}"));
3467 }
3468 } else {
3469 for child in object.values() {
3470 collect_image_upload_placeholders(child, placeholders)?;
3471 }
3472 }
3473 }
3474 _ => {}
3475 }
3476 Ok(())
3477}
3478
3479fn replace_image_upload_placeholders(
3480 value: &mut Value,
3481 refs: &std::collections::BTreeMap<String, Value>,
3482) -> Result<(), String> {
3483 match value {
3484 Value::Array(items) => {
3485 for item in items {
3486 replace_image_upload_placeholders(item, refs)?;
3487 }
3488 }
3489 Value::Object(object) => {
3490 if object.get("type").and_then(Value::as_str) == Some("image_upload") {
3491 let upload_id = image_upload_part_name(object, "image_upload")?;
3492 let replacement = refs
3493 .get(upload_id)
3494 .ok_or_else(|| format!("missing blob replacement for {upload_id}"))?;
3495 *value = replacement.clone();
3496 } else {
3497 for child in object.values_mut() {
3498 replace_image_upload_placeholders(child, refs)?;
3499 }
3500 }
3501 }
3502 _ => {}
3503 }
3504 Ok(())
3505}
3506
3507fn response_value(id: Value, result: Option<Value>, error: Option<JsonRpcError>) -> Value {
3508 serde_json::to_value(JsonRpcResponse {
3509 jsonrpc: JSONRPC_VERSION.to_string(),
3510 id,
3511 result,
3512 error,
3513 })
3514 .unwrap_or_else(|_| {
3515 serde_json::json!({
3516 "jsonrpc": JSONRPC_VERSION,
3517 "id": Value::Null,
3518 "error": {
3519 "code": -32603,
3520 "message": "serialization failed",
3521 }
3522 })
3523 })
3524}
3525
3526fn invalid_params(id: Value, message: impl Into<String>) -> Value {
3527 response_value(
3528 id,
3529 None,
3530 Some(JsonRpcError {
3531 code: -32602,
3532 message: message.into(),
3533 data: None,
3534 }),
3535 )
3536}
3537
3538fn gating_decision_failed_error(id: Value, err: impl std::fmt::Display) -> Value {
3539 tracing::warn!(
3540 target: "mobkit::console",
3541 error = %err,
3542 "console gating decision failed"
3543 );
3544 invalid_params(id, "gating decision failed")
3545}
3546
3547fn runtime_binding_from_wire(
3548 binding: WireRuntimeBinding,
3549) -> Result<meerkat_mob::RuntimeBinding, String> {
3550 match binding {
3551 WireRuntimeBinding::Session => Ok(meerkat_mob::RuntimeBinding::Session),
3552 WireRuntimeBinding::External {
3553 address,
3554 bootstrap_token,
3555 identity,
3556 } => {
3557 let resolved = identity.resolve().map_err(|err| err.to_string())?;
3558 Ok(meerkat_mob::RuntimeBinding::External {
3559 peer_id: resolved.peer_id.to_string(),
3560 address,
3561 bootstrap_token,
3562 pubkey: resolved.pubkey,
3563 })
3564 }
3565 }
3566}
3567
3568fn parse_optional_runtime_mode(params: &Value) -> Result<Option<MobRuntimeMode>, String> {
3569 match params.get("runtime_mode") {
3570 None | Some(Value::Null) => Ok(None),
3571 Some(value) => serde_json::from_value::<MobRuntimeMode>(value.clone())
3572 .map(Some)
3573 .map_err(|err| {
3574 format!("runtime_mode must be \"autonomous_host\" or \"turn_driven\": {err}")
3575 }),
3576 }
3577}
3578
3579fn parse_optional_backend(params: &Value) -> Result<Option<MobBackendKind>, String> {
3580 match params.get("backend") {
3581 None | Some(Value::Null) => Ok(None),
3582 Some(value) => serde_json::from_value::<MobBackendKind>(value.clone())
3583 .map(Some)
3584 .map_err(|err| format!("backend must be \"session\" or \"external\": {err}")),
3585 }
3586}
3587
3588fn parse_optional_runtime_binding(
3589 params: &Value,
3590) -> Result<Option<meerkat_mob::RuntimeBinding>, String> {
3591 match params.get("binding") {
3592 None | Some(Value::Null) => Ok(None),
3593 Some(value) => {
3594 let wire = serde_json::from_value::<WireRuntimeBinding>(value.clone())
3595 .map_err(|err| format!("binding: {err}"))?;
3596 runtime_binding_from_wire(wire).map(Some)
3597 }
3598 }
3599}
3600
3601async fn member_entry_to_console_json(
3602 runtime: &MobRuntime,
3603 entry: &meerkat_mob::runtime::MobMemberListEntry,
3604) -> Value {
3605 let mut value = member_entry_to_json(entry);
3606 if let Some(object) = value.as_object_mut() {
3607 object.insert(
3608 "model_capabilities".to_string(),
3609 serde_json::to_value(model_capabilities_for_member_entry(
3610 runtime.handle().definition(),
3611 entry,
3612 ))
3613 .unwrap_or(Value::Null),
3614 );
3615 }
3616 value
3617}
3618
3619fn internal_error(id: Value, message: impl Into<String>) -> Value {
3620 let message = message.into();
3621 tracing::warn!(
3622 target: "mobkit::console",
3623 error = %message,
3624 "console JSON-RPC internal error"
3625 );
3626 response_value(
3633 id,
3634 None,
3635 Some(JsonRpcError {
3636 code: -32000,
3637 message: message.clone(),
3638 data: Some(json!({ "error": "internal_error", "detail": message })),
3639 }),
3640 )
3641}
3642
3643fn stale_event_cursor_response(id: Value, after_cursor: u64, latest_cursor: u64) -> Value {
3648 response_value(
3649 id,
3650 None,
3651 Some(JsonRpcError {
3652 code: crate::rpc::MOB_EVENTS_STALE_CURSOR_CODE,
3653 message: format!(
3654 "stale mob event cursor: requested {after_cursor}, latest {latest_cursor}"
3655 ),
3656 data: Some(serde_json::json!({
3657 "error": "event_query_stale",
3658 "after_cursor": after_cursor,
3659 "latest_cursor": latest_cursor,
3660 })),
3661 }),
3662 )
3663}
3664
3665fn console_timeline_replay_unavailable_response(
3666 id: Value,
3667 err: ConsoleLogError,
3668 requested_cursor: Option<&ConsoleCursor>,
3669 latest_cursor: Option<ConsoleCursor>,
3670) -> Value {
3671 tracing::warn!(
3672 target: "mobkit::console",
3673 error = %err,
3674 "console timeline replay unavailable"
3675 );
3676 response_value(
3677 id,
3678 None,
3679 Some(JsonRpcError {
3680 code: crate::rpc::CONSOLE_TIMELINE_REPLAY_UNAVAILABLE_CODE,
3681 message: "timeline replay unavailable".to_string(),
3682 data: Some(json!({
3683 "error": "replay_unavailable",
3684 "stream": "timeline",
3685 "requested_cursor": requested_cursor.map(ToString::to_string),
3686 "latest_cursor": latest_cursor.map(|cursor| cursor.to_string()),
3687 })),
3688 }),
3689 )
3690}
3691
3692fn parse_console_helper_options(
3693 options_val: Option<&Value>,
3694) -> Result<meerkat_mob::HelperOptions, String> {
3695 crate::rpc::mob_methods::parse_helper_options(options_val)
3696}
3697
3698fn member_is_addressable(member: &meerkat_mob::runtime::MobMemberListEntry) -> bool {
3699 member
3700 .labels
3701 .get("addressable")
3702 .map(|value: &String| !value.eq_ignore_ascii_case("false"))
3703 .unwrap_or(true)
3704}
3705
3706fn member_addressability(member: &meerkat_mob::runtime::MobMemberListEntry) -> &'static str {
3707 if member_is_addressable(member) {
3708 "addressable"
3709 } else {
3710 "internal_only"
3711 }
3712}
3713
3714fn console_identity_status_json_for_identity(
3715 identity: &str,
3716 member: &meerkat_mob::runtime::MobMemberListEntry,
3717 session_id: Option<String>,
3718 response_phase: Option<String>,
3719) -> Value {
3720 json!({
3721 "identity": identity,
3722 "state": member_status_state_string(member.status),
3723 "role": member.role.to_string(),
3724 "addressability": member_addressability(member),
3725 "display_name": member.labels.get("display_name"),
3726 "labels": member.labels,
3727 "agent_runtime_id": crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()),
3728 "session_id": session_id,
3729 "generation": Value::Null,
3730 "checkpoint_version": Value::Null,
3731 "continuity_health": Value::Null,
3732 "lease_healthy": Value::Null,
3733 "lease": Value::Null,
3734 "response_phase": response_phase,
3735 })
3736}
3737
3738fn console_identity_inspect_json_for_identity(
3739 identity: &str,
3740 member: &meerkat_mob::runtime::MobMemberListEntry,
3741 session_id: Option<String>,
3742 response_phase: Option<String>,
3743) -> Value {
3744 let peers: Vec<String> = member.wired_to.iter().map(ToString::to_string).collect();
3745 json!({
3746 "identity": identity,
3747 "state": member_status_state_string(member.status),
3748 "role": member.role.to_string(),
3749 "addressability": member_addressability(member),
3750 "display_name": member.labels.get("display_name"),
3751 "labels": member.labels,
3752 "continuity_health": Value::Null,
3753 "lease_healthy": Value::Null,
3754 "lease": Value::Null,
3755 "continuity": {
3756 "generation": Value::Null,
3757 "checkpoint_version": Value::Null,
3758 "session_id": session_id,
3759 "agent_runtime_id": crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()),
3760 },
3761 "topology_peers": peers,
3762 "output_preview": Value::Null,
3763 "response_phase": response_phase,
3764 })
3765}
3766
3767async fn lookup_member_with_session(
3771 handle: &MobHandle,
3772 identity: &AgentIdentity,
3773) -> Option<(meerkat_mob::runtime::MobMemberListEntry, Option<String>)> {
3774 let entries = handle.list_members_including_retiring().await;
3775 let entry = entries
3776 .into_iter()
3777 .find(|e| &e.agent_identity == identity)?;
3778 let session_id = handle
3779 .resolve_bridge_session_id_observation(identity)
3780 .await
3781 .map(|s| s.to_string());
3782 Some((entry, session_id))
3783}
3784
3785#[derive(Debug, Clone)]
3786struct ConsoleRuntimeIdentityAlias {
3787 identity: String,
3788 runtime_member_id: String,
3789 member: meerkat_mob::runtime::MobMemberListEntry,
3790 session_id: Option<String>,
3791}
3792
3793fn durable_identity_for_member(member: &meerkat_mob::runtime::MobMemberListEntry) -> String {
3794 member
3795 .labels
3796 .get("agent_identity")
3797 .filter(|value| !value.trim().is_empty())
3798 .cloned()
3799 .unwrap_or_else(|| {
3801 crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned()
3802 })
3803}
3804
3805async fn lookup_member_alias_with_session(
3806 handle: &MobHandle,
3807 visibility_policy: &dyn ConsoleVisibilityPolicy,
3808 requested_identity: &str,
3809) -> Result<Option<ConsoleRuntimeIdentityAlias>, JsonRpcError> {
3810 let all_matches = lookup_member_alias_candidates_with_session(handle, requested_identity).await;
3811 let mut visible_matches = Vec::new();
3812 for alias in &all_matches {
3813 if runtime_alias_visible_to_console(handle, visibility_policy, alias) {
3814 visible_matches.push(alias.clone());
3815 }
3816 }
3817 let member = if visible_matches.len() > 1 {
3818 return Err(ambiguous_live_identity_alias_error(
3819 requested_identity,
3820 &visible_matches
3821 .iter()
3822 .map(|alias| alias.runtime_member_id.clone())
3823 .collect::<Vec<_>>(),
3824 ));
3825 } else if let Some(alias) = visible_matches.into_iter().next() {
3826 Some(alias)
3827 } else {
3828 all_matches.into_iter().next()
3829 };
3830 Ok(member)
3831}
3832
3833async fn lookup_visible_member_alias_candidates_with_session(
3834 handle: &MobHandle,
3835 visibility_policy: &dyn ConsoleVisibilityPolicy,
3836 requested_identity: &str,
3837) -> Vec<ConsoleRuntimeIdentityAlias> {
3838 let mut visible = Vec::new();
3839 for alias in lookup_member_alias_candidates_with_session(handle, requested_identity).await {
3840 if runtime_alias_visible_to_console(handle, visibility_policy, &alias) {
3841 visible.push(alias);
3842 }
3843 }
3844 visible
3845}
3846
3847async fn lookup_member_alias_candidates_with_session(
3848 handle: &MobHandle,
3849 requested_identity: &str,
3850) -> Vec<ConsoleRuntimeIdentityAlias> {
3851 let requested_member_id = crate::member_comms_id::mob_member_id(requested_identity);
3854 let entries = handle.list_members_including_retiring().await;
3855 let exact_matches = entries
3856 .iter()
3857 .filter(|entry| entry.agent_identity == requested_member_id)
3858 .cloned()
3859 .collect::<Vec<_>>();
3860 let label_matches = entries
3861 .iter()
3862 .filter(|entry| {
3863 entry
3864 .labels
3865 .get("agent_identity")
3866 .is_some_and(|identity| identity == requested_identity)
3867 })
3868 .cloned()
3869 .collect::<Vec<_>>();
3870 let mut matches = exact_matches;
3871 matches.extend(label_matches);
3872 let mut seen_member_ids = BTreeSet::new();
3873 matches.retain(|entry| seen_member_ids.insert(entry.agent_identity.to_string()));
3874 let mut aliases = Vec::with_capacity(matches.len());
3875 for member in matches {
3876 let runtime_member_id =
3877 crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned();
3878 let identity = durable_identity_for_member(&member);
3879 let session_id = handle
3880 .resolve_bridge_session_id_observation(&member.agent_identity)
3881 .await
3882 .map(|s| s.to_string());
3883 aliases.push(ConsoleRuntimeIdentityAlias {
3884 identity,
3885 runtime_member_id,
3886 member,
3887 session_id,
3888 });
3889 }
3890 aliases
3891}
3892
3893async fn reject_ambiguous_projected_live_identity(
3894 handle: &MobHandle,
3895 visibility_policy: &dyn ConsoleVisibilityPolicy,
3896 alias: &ConsoleRuntimeIdentityAlias,
3897) -> Result<(), JsonRpcError> {
3898 let candidates = lookup_visible_member_alias_candidates_with_session(
3899 handle,
3900 visibility_policy,
3901 &alias.identity,
3902 )
3903 .await;
3904 if candidates.len() > 1 {
3905 return Err(ambiguous_live_identity_alias_error(
3906 &alias.identity,
3907 &candidates
3908 .iter()
3909 .map(|candidate| candidate.runtime_member_id.clone())
3910 .collect::<Vec<_>>(),
3911 ));
3912 }
3913 Ok(())
3914}
3915
3916fn ambiguous_live_identity_alias_error(
3917 requested_identity: &str,
3918 candidates: &[String],
3919) -> JsonRpcError {
3920 JsonRpcError {
3921 code: -32602,
3922 message: format!(
3923 "ambiguous live identity alias {requested_identity}: candidates [{}]",
3924 candidates.join(", ")
3925 ),
3926 data: Some(json!({
3927 "kind": "ambiguous_live_identity_alias",
3928 "identity": requested_identity,
3929 "candidates": candidates,
3930 })),
3931 }
3932}
3933
3934async fn lookup_member_runtime_alias_with_session(
3935 handle: &MobHandle,
3936 runtime_member_id: &str,
3937) -> Option<ConsoleRuntimeIdentityAlias> {
3938 let requested_member_id = crate::member_comms_id::mob_member_id(runtime_member_id);
3939 let entries = handle.list_members_including_retiring().await;
3940 let member = entries
3941 .into_iter()
3942 .find(|entry| entry.agent_identity == requested_member_id)?;
3943 let runtime_member_id =
3944 crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned();
3945 let identity = durable_identity_for_member(&member);
3946 let session_id = handle
3947 .resolve_bridge_session_id_observation(&member.agent_identity)
3948 .await
3949 .map(|s| s.to_string());
3950 Some(ConsoleRuntimeIdentityAlias {
3951 identity,
3952 runtime_member_id,
3953 member,
3954 session_id,
3955 })
3956}
3957
3958async fn identity_runtime_alias(
3959 identity_runtime: &crate::identity_first::IdentityRuntime,
3960 requested_identity: &str,
3961) -> Result<Option<(crate::identity_first::AgentIdentity, bool)>, String> {
3962 if requested_identity.starts_with("rt:") {
3963 for status in identity_runtime.statuses().await {
3964 if status
3965 .agent_runtime_id
3966 .as_ref()
3967 .is_some_and(|runtime_id| runtime_id.as_str() == requested_identity)
3968 {
3969 return Ok(Some((status.identity, false)));
3970 }
3971 }
3972 return Ok(None);
3973 }
3974 if let Ok(identity) = crate::identity_first::AgentIdentity::parse(requested_identity) {
3975 match identity_runtime.status(&identity).await {
3976 Ok(_) => return Ok(Some((identity, true))),
3977 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
3978 Err(err) => return Err(err.to_string()),
3979 }
3980 }
3981
3982 for status in identity_runtime.statuses().await {
3983 if status
3984 .agent_runtime_id
3985 .as_ref()
3986 .is_some_and(|runtime_id| runtime_id.as_str() == requested_identity)
3987 {
3988 return Ok(Some((status.identity, false)));
3989 }
3990 }
3991 Ok(None)
3992}
3993
3994async fn resolve_console_identity_control_target(
3995 handle: &MobHandle,
3996 identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
3997 visibility_policy: &dyn ConsoleVisibilityPolicy,
3998 requested_identity: &str,
3999) -> Result<
4000 Option<(
4001 crate::identity_first::AgentIdentity,
4002 bool,
4003 Option<ConsoleRuntimeIdentityAlias>,
4004 )>,
4005 JsonRpcError,
4006> {
4007 if let Some(identity_runtime) = identity_runtime {
4008 match identity_runtime_alias(identity_runtime, requested_identity).await {
4009 Ok(Some((identity, exact))) => {
4010 let live = if exact {
4011 let registered_live = match identity_runtime.status(&identity).await {
4012 Ok(status) => match status.agent_runtime_id.as_ref() {
4013 Some(runtime_id) => {
4014 lookup_member_runtime_alias_with_session(
4015 handle,
4016 runtime_id.as_str(),
4017 )
4018 .await
4019 }
4020 None => None,
4021 },
4022 Err(_) => None,
4023 };
4024 if let Some(alias) = registered_live.as_ref()
4025 && !runtime_alias_visible_to_console(handle, visibility_policy, alias)
4026 {
4027 return Err(identity_hidden_by_policy_error(requested_identity));
4028 }
4029 if let Some(registered) = registered_live {
4030 return Ok(Some((identity, exact, Some(registered))));
4031 }
4032 let requested_live_candidates =
4033 lookup_visible_member_alias_candidates_with_session(
4034 handle,
4035 visibility_policy,
4036 requested_identity,
4037 )
4038 .await;
4039 let requested_live = if requested_live_candidates.len() > 1 {
4040 return Err(ambiguous_live_identity_alias_error(
4041 requested_identity,
4042 &requested_live_candidates
4043 .iter()
4044 .map(|alias| alias.runtime_member_id.clone())
4045 .collect::<Vec<_>>(),
4046 ));
4047 } else {
4048 requested_live_candidates.into_iter().next()
4049 };
4050 match (registered_live, requested_live) {
4051 (Some(registered), Some(requested))
4052 if registered.runtime_member_id == requested.runtime_member_id =>
4053 {
4054 Some(registered)
4055 }
4056 (Some(registered), None) => Some(registered),
4057 (Some(_registered), Some(requested)) => Some(requested),
4058 (None, requested) => requested,
4059 }
4060 } else {
4061 let registered_live =
4062 lookup_member_runtime_alias_with_session(handle, requested_identity).await;
4063 if let Some(alias) = registered_live.as_ref()
4064 && !runtime_alias_visible_to_console(handle, visibility_policy, alias)
4065 {
4066 return Err(identity_hidden_by_policy_error(requested_identity));
4067 }
4068 if let Some(registered) = registered_live {
4069 return Ok(Some((identity, exact, Some(registered))));
4070 }
4071 let durable_live_candidates =
4072 lookup_visible_member_alias_candidates_with_session(
4073 handle,
4074 visibility_policy,
4075 identity.as_str(),
4076 )
4077 .await;
4078 let durable_live = if durable_live_candidates.len() > 1 {
4079 return Err(ambiguous_live_identity_alias_error(
4080 identity.as_str(),
4081 &durable_live_candidates
4082 .iter()
4083 .map(|alias| alias.runtime_member_id.clone())
4084 .collect::<Vec<_>>(),
4085 ));
4086 } else {
4087 durable_live_candidates.into_iter().next()
4088 };
4089 match (registered_live, durable_live) {
4090 (Some(registered), Some(durable))
4091 if registered.runtime_member_id == durable.runtime_member_id =>
4092 {
4093 Some(registered)
4094 }
4095 (Some(registered), None) => Some(registered),
4096 (Some(_registered), Some(durable)) => Some(durable),
4097 (None, durable) => durable,
4098 }
4099 };
4100 return Ok(Some((identity, exact, live)));
4101 }
4102 Ok(None) => {}
4103 Err(err) => {
4104 return Err(JsonRpcError {
4105 code: -32000,
4106 message: err,
4107 data: None,
4108 });
4109 }
4110 }
4111 }
4112
4113 let live_alias =
4114 lookup_member_alias_with_session(handle, visibility_policy, requested_identity).await?;
4115 let Some(alias) = live_alias else {
4116 return Ok(None);
4117 };
4118 if let Some(identity_runtime) = identity_runtime
4119 && let Some(bound_status) = identity_runtime
4120 .statuses()
4121 .await
4122 .into_iter()
4123 .find(|status| {
4124 status
4125 .agent_runtime_id
4126 .as_ref()
4127 .is_some_and(|runtime_id| runtime_id.as_str() == alias.runtime_member_id)
4128 })
4129 && bound_status.identity.as_str() != alias.identity
4130 {
4131 return Err(JsonRpcError {
4132 code: -32000,
4133 message: format!(
4134 "stale live identity alias: live console alias {} resolves to {}, but identity runtime binding belongs to {}",
4135 alias.identity,
4136 alias.runtime_member_id,
4137 bound_status.identity.as_str(),
4138 ),
4139 data: Some(json!({
4140 "kind": "stale_live_identity_alias",
4141 "identity": alias.identity,
4142 "runtime_member_id": alias.runtime_member_id,
4143 "registered_identity": bound_status.identity.as_str(),
4144 })),
4145 });
4146 }
4147 let identity = crate::identity_first::AgentIdentity::parse(&alias.identity).map_err(|err| {
4148 JsonRpcError {
4149 code: -32602,
4150 message: format!("invalid identity: {err}"),
4151 data: None,
4152 }
4153 })?;
4154 let durable_live_candidates = lookup_visible_member_alias_candidates_with_session(
4155 handle,
4156 visibility_policy,
4157 identity.as_str(),
4158 )
4159 .await;
4160 if durable_live_candidates.len() > 1 {
4161 return Err(ambiguous_live_identity_alias_error(
4162 identity.as_str(),
4163 &durable_live_candidates
4164 .iter()
4165 .map(|alias| alias.runtime_member_id.clone())
4166 .collect::<Vec<_>>(),
4167 ));
4168 }
4169 Ok(Some((identity, false, Some(alias))))
4170}
4171
4172fn live_alias_matches_status_runtime(
4173 alias: Option<&ConsoleRuntimeIdentityAlias>,
4174 status: &crate::identity_first::IdentityStatus,
4175) -> bool {
4176 let Some(alias) = alias else {
4177 return true;
4178 };
4179 let session_matches = match (
4180 status.session_id.as_ref().map(ToString::to_string),
4181 alias.session_id.as_deref(),
4182 ) {
4183 (Some(status_session), Some(live_session)) => status_session == live_session,
4184 _ => true,
4185 };
4186 status
4187 .agent_runtime_id
4188 .as_ref()
4189 .is_some_and(|runtime_id| runtime_id.as_str() == alias.runtime_member_id)
4190 && alias.identity == status.identity.as_str()
4191 && session_matches
4192}
4193
4194async fn stale_live_alias_json_rpc_error(
4195 operation: &str,
4196 identity_runtime: &crate::identity_first::IdentityRuntime,
4197 identity: &crate::identity_first::AgentIdentity,
4198 live_alias: Option<&ConsoleRuntimeIdentityAlias>,
4199) -> Option<JsonRpcError> {
4200 let live_alias = live_alias?;
4201 let Ok(status) = identity_runtime.status(identity).await else {
4202 return None;
4203 };
4204 if live_alias_matches_status_runtime(Some(live_alias), &status) {
4205 return None;
4206 }
4207 let registered_runtime_member_id = status
4208 .agent_runtime_id
4209 .as_ref()
4210 .map(crate::identity_first::AgentRuntimeId::as_str);
4211 Some(JsonRpcError {
4212 code: -32000,
4213 message: format!(
4214 "{operation} failed: identity runtime binding for {} points at {}, but requested live member is {}",
4215 identity.as_str(),
4216 registered_runtime_member_id.unwrap_or("<none>"),
4217 live_alias.runtime_member_id
4218 ),
4219 data: Some(json!({
4220 "kind": "stale_identity_runtime_binding",
4221 "identity": identity.as_str(),
4222 "registered_runtime_member_id": registered_runtime_member_id,
4223 "live_runtime_member_id": live_alias.runtime_member_id,
4224 "registered_session_id": status.session_id.as_ref().map(ToString::to_string),
4225 "live_session_id": live_alias.session_id,
4226 })),
4227 })
4228}
4229
4230fn identity_from_runtime_alias(alias: &str) -> Option<crate::identity_first::AgentIdentity> {
4231 let rest = alias.strip_prefix("rt:")?;
4232 let (identity, generation) = rest.rsplit_once(':')?;
4233 if identity.is_empty()
4234 || generation.is_empty()
4235 || !generation.chars().all(|ch| ch.is_ascii_digit())
4236 {
4237 return None;
4238 }
4239 crate::identity_first::AgentIdentity::parse(identity).ok()
4240}
4241
4242async fn stale_runtime_alias_json_rpc_error(
4243 operation: &str,
4244 identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
4245 alias: &str,
4246) -> Option<JsonRpcError> {
4247 let identity_runtime = identity_runtime?;
4248 let identity = identity_from_runtime_alias(alias)?;
4249 let Ok(status) = identity_runtime.status(&identity).await else {
4250 return None;
4251 };
4252 let registered_runtime_member_id = status
4253 .agent_runtime_id
4254 .as_ref()
4255 .map(crate::identity_first::AgentRuntimeId::as_str);
4256 if registered_runtime_member_id == Some(alias) {
4257 return None;
4258 }
4259 Some(JsonRpcError {
4260 code: -32000,
4261 message: format!(
4262 "{operation} failed: identity runtime binding for {} points at {}, but requested live member is {}",
4263 identity.as_str(),
4264 registered_runtime_member_id.unwrap_or("<none>"),
4265 alias
4266 ),
4267 data: Some(json!({
4268 "kind": "stale_identity_runtime_binding",
4269 "identity": identity.as_str(),
4270 "registered_runtime_member_id": registered_runtime_member_id,
4271 "live_runtime_member_id": alias,
4272 "registered_session_id": status.session_id.as_ref().map(ToString::to_string),
4273 "live_session_id": Value::Null,
4274 })),
4275 })
4276}
4277
4278fn reset_requires_session_bridge_json_rpc_error() -> JsonRpcError {
4279 JsonRpcError {
4280 code: -32602,
4281 message: "reset requires an identity runtime with a session bridge".to_string(),
4282 data: Some(json!({
4283 "kind": "identity_reset_requires_session_bridge",
4284 })),
4285 }
4286}
4287
4288fn console_identity_status_json_from_record(
4289 record: &crate::console_aggregator::ConsoleIdentityRecord,
4290 response_phase: Option<String>,
4291) -> Value {
4292 json!({
4293 "identity": record.identity,
4294 "state": record.health,
4295 "role": record.labels.get("role"),
4296 "addressability": if record.addressable { "addressable" } else { "internal_only" },
4297 "display_name": record.display_name,
4298 "labels": record.labels,
4299 "agent_runtime_id": record.runtime_member_id,
4300 "session_id": record.session_id,
4301 "generation": Value::Null,
4302 "checkpoint_version": Value::Null,
4303 "continuity_health": Value::Null,
4304 "lease_healthy": Value::Null,
4305 "lease": Value::Null,
4306 "response_phase": response_phase,
4307 })
4308}
4309
4310fn console_addressability_json(
4311 addressability: crate::identity_first::AgentAddressability,
4312) -> &'static str {
4313 match addressability {
4314 crate::identity_first::AgentAddressability::Addressable => "addressable",
4315 crate::identity_first::AgentAddressability::InternalOnly => "internal_only",
4316 }
4317}
4318
4319fn console_identity_record_from_identity_status(
4320 status: &crate::identity_first::IdentityStatus,
4321) -> ConsoleIdentityRecord {
4322 let mut labels = status.labels.clone();
4323 if let Some(profile) = status.profile.as_ref() {
4324 labels
4325 .entry("role".to_string())
4326 .or_insert_with(|| profile.as_str().to_string());
4327 }
4328 let runtime_member_id = status
4329 .agent_runtime_id
4330 .as_ref()
4331 .map(crate::identity_first::AgentRuntimeId::as_str)
4332 .unwrap_or_else(|| status.identity.as_str())
4333 .to_string();
4334 let addressable = status.addressability
4335 == crate::identity_first::AgentAddressability::Addressable
4336 && matches!(
4337 status.state,
4338 crate::identity_first::IdentityLifecycleState::Active
4339 | crate::identity_first::IdentityLifecycleState::Dormant
4340 | crate::identity_first::IdentityLifecycleState::Uninitialized
4341 );
4342 let visibility = match status.state {
4343 crate::identity_first::IdentityLifecycleState::Retiring => {
4344 ConsoleVisibility::RetiredReadable
4345 }
4346 crate::identity_first::IdentityLifecycleState::Broken
4347 | crate::identity_first::IdentityLifecycleState::Suspended => {
4348 ConsoleVisibility::Unreachable
4349 }
4350 _ if addressable => ConsoleVisibility::Addressable,
4351 _ => ConsoleVisibility::Hidden,
4352 };
4353 let health = match status.state {
4354 crate::identity_first::IdentityLifecycleState::Active => "ready",
4355 crate::identity_first::IdentityLifecycleState::Dormant => "dormant",
4356 crate::identity_first::IdentityLifecycleState::Uninitialized => "uninitialized",
4357 crate::identity_first::IdentityLifecycleState::Broken => "broken",
4358 crate::identity_first::IdentityLifecycleState::Suspended => "suspended",
4359 crate::identity_first::IdentityLifecycleState::Retiring => "retired",
4360 }
4361 .to_string();
4362 ConsoleIdentityRecord {
4363 identity: status.identity.as_str().to_string(),
4364 display_name: status
4365 .display_name
4366 .as_ref()
4367 .map(crate::identity_first::DisplayName::as_str)
4368 .unwrap_or_else(|| status.identity.as_str())
4369 .to_string(),
4370 runtime_key: "identity-first".to_string(),
4371 runtime_member_id,
4372 session_id: status.session_id.as_ref().map(ToString::to_string),
4373 visibility,
4374 addressable,
4375 health,
4376 topology_peers: Vec::new(),
4377 labels,
4378 }
4379}
4380
4381fn identity_hidden_by_policy_response(response_id: Value, identity: &str) -> Value {
4382 response_value(
4383 response_id,
4384 None,
4385 Some(identity_hidden_by_policy_error(identity)),
4386 )
4387}
4388
4389fn identity_hidden_by_policy_error(identity: &str) -> JsonRpcError {
4390 JsonRpcError {
4391 code: -32001,
4392 message: format!("unknown identity: {identity}"),
4393 data: Some(json!({
4394 "kind": "identity_hidden_by_policy",
4395 "identity": identity,
4396 })),
4397 }
4398}
4399
4400fn identity_status_visible_to_console(
4401 visibility_policy: &dyn ConsoleVisibilityPolicy,
4402 status: &crate::identity_first::IdentityStatus,
4403) -> bool {
4404 visibility_policy.identity_visible(&console_identity_record_from_identity_status(status))
4405}
4406
4407fn console_member_from_runtime_alias(
4408 handle: &MobHandle,
4409 alias: &ConsoleRuntimeIdentityAlias,
4410) -> ConsoleMember {
4411 ConsoleMember {
4412 agent_identity: alias.runtime_member_id.clone(),
4413 role: alias.member.role.to_string(),
4414 state: member_status_state_string(alias.member.status),
4415 model_capabilities: model_capabilities_for_member_entry(handle.definition(), &alias.member),
4416 runtime_mode: Some(alias.member.runtime_mode.to_string()),
4417 session_id: alias.session_id.clone(),
4418 wired_to: alias
4419 .member
4420 .wired_to
4421 .iter()
4422 .map(|peer| crate::member_comms_id::runtime_alias_str(peer.as_str()).into_owned())
4423 .collect(),
4424 labels: alias.member.labels.clone(),
4425 progress: None,
4426 }
4427}
4428
4429fn console_identity_record_from_runtime_alias(
4430 alias: &ConsoleRuntimeIdentityAlias,
4431) -> ConsoleIdentityRecord {
4432 let addressable = alias
4433 .member
4434 .labels
4435 .get("addressable")
4436 .map(|value| !value.eq_ignore_ascii_case("false"))
4437 .unwrap_or(true)
4438 && alias.member.status == meerkat_mob::MobMemberStatus::Active;
4439 let visibility = match alias.member.status {
4440 meerkat_mob::MobMemberStatus::Retiring | meerkat_mob::MobMemberStatus::Completed => {
4441 ConsoleVisibility::RetiredReadable
4442 }
4443 meerkat_mob::MobMemberStatus::Broken | meerkat_mob::MobMemberStatus::Unknown => {
4446 ConsoleVisibility::Unreachable
4447 }
4448 _ if addressable => ConsoleVisibility::Addressable,
4449 _ => ConsoleVisibility::Hidden,
4450 };
4451 ConsoleIdentityRecord {
4452 identity: alias.identity.clone(),
4453 display_name: alias
4454 .member
4455 .labels
4456 .get("display_name")
4457 .cloned()
4458 .unwrap_or_else(|| alias.identity.clone()),
4459 runtime_key: "runtime".to_string(),
4460 runtime_member_id: alias.runtime_member_id.clone(),
4461 session_id: alias.session_id.clone(),
4462 visibility,
4463 addressable,
4464 health: match alias.member.status {
4465 meerkat_mob::MobMemberStatus::Active => "ready".to_string(),
4466 meerkat_mob::MobMemberStatus::Retiring => "retired".to_string(),
4467 other => format!("{other:?}").to_ascii_lowercase(),
4470 },
4471 topology_peers: alias
4472 .member
4473 .wired_to
4474 .iter()
4475 .map(ToString::to_string)
4476 .collect(),
4477 labels: alias.member.labels.clone(),
4478 }
4479}
4480
4481fn runtime_alias_visible_to_console(
4482 handle: &MobHandle,
4483 visibility_policy: &dyn ConsoleVisibilityPolicy,
4484 alias: &ConsoleRuntimeIdentityAlias,
4485) -> bool {
4486 let member = console_member_from_runtime_alias(handle, alias);
4487 if !visibility_policy.member_visible(&member) {
4488 return false;
4489 }
4490 visibility_policy.identity_visible(&console_identity_record_from_runtime_alias(alias))
4491}
4492
4493fn console_identity_status_json_from_identity_status(
4494 status: &crate::identity_first::IdentityStatus,
4495 response_phase: Option<String>,
4496) -> Value {
4497 json!({
4498 "identity": status.identity.as_str(),
4499 "state": status.state.wire_str(),
4500 "role": status.profile.as_ref().map(ProfileName::as_str),
4501 "addressability": console_addressability_json(status.addressability),
4502 "display_name": status.display_name.as_ref().map(crate::identity_first::DisplayName::as_str),
4503 "labels": status.labels,
4504 "agent_runtime_id": status.agent_runtime_id.as_ref().map(crate::identity_first::AgentRuntimeId::as_str),
4505 "session_id": status.session_id.as_ref().map(ToString::to_string),
4506 "generation": status.generation.map(crate::identity_first::ContinuityGeneration::get),
4507 "checkpoint_version": status.checkpoint_version.map(crate::identity_first::CheckpointVersion::get),
4508 "continuity_health": status.continuity_health,
4509 "lease_healthy": status.lease.as_ref().map(|lease| lease.healthy),
4510 "lease": status.lease.as_ref().map(|lease| json!({
4511 "fencing_token": lease.fencing_token.get(),
4512 "ttl_remaining_ms": lease.ttl_remaining.as_millis() as u64,
4513 "healthy": lease.healthy,
4514 })),
4515 "response_phase": response_phase,
4516 })
4517}
4518
4519fn console_identity_inspect_json_from_identity_status(
4520 status: &crate::identity_first::IdentityStatus,
4521 live_alias: Option<&ConsoleRuntimeIdentityAlias>,
4522 response_phase: Option<String>,
4523) -> Value {
4524 let topology_peers = live_alias
4525 .map(|alias| {
4526 alias
4527 .member
4528 .wired_to
4529 .iter()
4530 .map(ToString::to_string)
4531 .map(Value::String)
4532 .collect::<Vec<_>>()
4533 })
4534 .unwrap_or_default();
4535 let session_id = status
4536 .session_id
4537 .as_ref()
4538 .map(ToString::to_string)
4539 .or_else(|| live_alias.and_then(|alias| alias.session_id.clone()));
4540 let agent_runtime_id = status
4541 .agent_runtime_id
4542 .as_ref()
4543 .map(crate::identity_first::AgentRuntimeId::as_str)
4544 .map(ToString::to_string)
4545 .or_else(|| live_alias.map(|alias| alias.runtime_member_id.clone()));
4546 json!({
4547 "identity": status.identity.as_str(),
4548 "state": status.state.wire_str(),
4549 "role": status.profile.as_ref().map(ProfileName::as_str),
4550 "addressability": console_addressability_json(status.addressability),
4551 "display_name": status.display_name.as_ref().map(crate::identity_first::DisplayName::as_str),
4552 "labels": status.labels,
4553 "continuity_health": status.continuity_health,
4554 "lease_healthy": status.lease.as_ref().map(|lease| lease.healthy),
4555 "lease": status.lease.as_ref().map(|lease| json!({
4556 "fencing_token": lease.fencing_token.get(),
4557 "ttl_remaining_ms": lease.ttl_remaining.as_millis() as u64,
4558 "healthy": lease.healthy,
4559 })),
4560 "continuity": {
4561 "generation": status.generation.map(crate::identity_first::ContinuityGeneration::get),
4562 "checkpoint_version": status.checkpoint_version.map(crate::identity_first::CheckpointVersion::get),
4563 "session_id": session_id,
4564 "agent_runtime_id": agent_runtime_id,
4565 },
4566 "topology_peers": topology_peers,
4567 "output_preview": Value::Null,
4568 "response_phase": response_phase,
4569 })
4570}
4571
4572fn console_identity_inspect_json_from_record(
4573 inspection: &crate::console_aggregator::ConsoleIdentityInspection,
4574 response_phase: Option<String>,
4575) -> Value {
4576 let record = &inspection.identity;
4577 json!({
4578 "identity": record.identity,
4579 "state": record.health,
4580 "role": record.labels.get("role"),
4581 "addressability": if record.addressable { "addressable" } else { "internal_only" },
4582 "display_name": record.display_name,
4583 "labels": record.labels,
4584 "continuity_health": Value::Null,
4585 "lease_healthy": Value::Null,
4586 "lease": Value::Null,
4587 "continuity": {
4588 "generation": Value::Null,
4589 "checkpoint_version": Value::Null,
4590 "session_id": record.session_id,
4591 "agent_runtime_id": record.runtime_member_id,
4592 },
4593 "topology_peers": inspection.peers,
4594 "output_preview": Value::Null,
4595 "response_phase": response_phase,
4596 })
4597}
4598
4599fn lifecycle_archive_cleanup_completed(error: &str) -> bool {
4600 is_recoverable_lifecycle_cleanup_error(error)
4601}
4602
4603async fn respawn_console_member(
4604 handle: &MobHandle,
4605 runtime_member_id: &AgentIdentity,
4606) -> Result<Option<Value>, String> {
4607 let entry_before_respawn = handle.get_member(runtime_member_id).await.ok().flatten();
4610 match handle.respawn(runtime_member_id.clone(), None).await {
4611 Ok(_receipt) => Ok(None),
4612 Err(err) => {
4613 if let Some(failed_peer_ids) = topology_restore_failed_peer_ids(&err) {
4614 tracing::warn!(
4615 member_id = %runtime_member_id,
4616 failed_peer_count = failed_peer_ids.len(),
4617 failed_peer_ids = ?failed_peer_ids,
4618 "console member respawn restored member with isolated peer edges; continuing degraded respawn"
4619 );
4620 return Ok(Some(topology_restore_warning_json(&failed_peer_ids)));
4621 }
4622
4623 if lifecycle_archive_cleanup_completed(&err.to_string()) {
4624 if handle
4627 .get_member(runtime_member_id)
4628 .await
4629 .map_err(|lookup_err| lookup_err.to_string())?
4630 .is_none()
4631 && let Some(entry) = entry_before_respawn
4632 {
4633 let mut spec =
4634 SpawnMemberSpec::new(entry.role.clone(), runtime_member_id.clone());
4635 if !entry.labels.is_empty() {
4636 spec = spec.with_labels(entry.labels.clone());
4637 }
4638 handle
4639 .ensure_member(spec)
4640 .await
4641 .map_err(|ensure_err| ensure_err.to_string())?;
4642 }
4643 return Ok(None);
4644 }
4645
4646 Err(err.to_string())
4647 }
4648 }
4649}
4650
4651async fn respawn_live_console_member_response(
4655 handle: &MobHandle,
4656 console_events: Option<&ConsoleEventStore>,
4657 alias: &ConsoleRuntimeIdentityAlias,
4658 lifecycle_kind: &str,
4659 action: &str,
4660 response_id: Value,
4661) -> Value {
4662 let mid = crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str());
4663 match Box::pin(respawn_console_member(handle, &mid)).await {
4664 Ok(topology_restore_warning) => {
4665 if let Some(store) = console_events {
4666 store
4667 .record_lifecycle(
4668 &alias.identity,
4669 lifecycle_kind,
4670 json!({ "topology_restore_warning": topology_restore_warning.clone() }),
4671 )
4672 .await;
4673 }
4674 let mut body = match lookup_member_with_session(handle, &mid).await {
4675 Some((entry, session_id)) => console_identity_status_json_for_identity(
4676 &alias.identity,
4677 &entry,
4678 session_id,
4679 None,
4680 ),
4681 None => json!({ "identity": alias.identity }),
4682 };
4683 if let Some(warning) = topology_restore_warning {
4684 body["topology_restore_warning"] = warning;
4685 }
4686 response_value(response_id, Some(body), None)
4687 }
4688 Err(err) => internal_error(response_id, format!("{action} failed: {err}")),
4689 }
4690}
4691
4692async fn retire_console_member(
4693 handle: &MobHandle,
4694 runtime_member_id: &AgentIdentity,
4695) -> Result<(), String> {
4696 match handle.retire(runtime_member_id.clone()).await {
4697 Ok(()) => Ok(()),
4698 Err(err) if lifecycle_archive_cleanup_completed(&err.to_string()) => Ok(()),
4699 Err(err) => Err(err.to_string()),
4700 }
4701}
4702
4703#[cfg(test)]
4704fn member_id_matches_durable_identity(member_id: &str, durable_identity: &str) -> bool {
4705 member_id == durable_identity
4706}
4707
4708async fn retire_stale_console_members_for_identity(
4709 handle: &MobHandle,
4710 visibility_policy: &dyn ConsoleVisibilityPolicy,
4711 durable_identity: &str,
4712 keep_runtime_member_id: Option<&str>,
4713) -> Result<(), String> {
4714 let stale_members = lookup_member_alias_candidates_with_session(handle, durable_identity)
4715 .await
4716 .into_iter()
4717 .filter(|alias| {
4718 runtime_alias_visible_to_console(handle, visibility_policy, alias)
4719 && keep_runtime_member_id
4720 .map(|keep| alias.runtime_member_id != keep)
4721 .unwrap_or(true)
4722 })
4723 .map(|alias| crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str()))
4724 .collect::<Vec<_>>();
4725 for member_id in stale_members {
4726 retire_console_member(handle, &member_id).await?;
4727 }
4728 Ok(())
4729}
4730
4731fn console_identity_error_response(
4732 response_id: Value,
4733 operation: &str,
4734 err: crate::identity_first::IdentityRuntimeError,
4735) -> Value {
4736 match err {
4737 crate::identity_first::IdentityRuntimeError::UnknownIdentity(identity) => {
4738 invalid_params(response_id, format!("identity not found: {identity}"))
4739 }
4740 other => internal_error(response_id, format!("{operation} failed: {other}")),
4741 }
4742}
4743
4744#[allow(clippy::too_many_arguments)]
4745async fn handle_console_aggregator_rpc(
4746 console_aggregator: Option<MobKitConsoleAggregator>,
4747 request: JsonRpcRequest,
4748 is_authenticated: bool,
4749 read_only: bool,
4750 access: Option<&AccessController>,
4751 access_view: Option<&AccessView>,
4752) -> Value {
4753 let response_id = request.id.clone().unwrap_or(Value::Null);
4754 let can_mutate = is_authenticated && !read_only;
4755 if is_console_mutating_rpc_method(request.method.as_str()) && !can_mutate {
4756 return console_read_only_rpc_error(response_id);
4757 }
4758 if let Some(response) = handle_access_admin_rpc(access, access_view, &request) {
4759 return response;
4760 }
4761 if let Some(error) =
4762 console_rpc_access_violation(access_view, request.method.as_str(), &request.params)
4763 {
4764 return response_value(response_id, None, Some(error));
4765 }
4766 match request.method.as_str() {
4767 "mobkit/capabilities" => {
4768 let mut methods = vec![
4769 "mobkit/capabilities",
4770 "mobkit/console/list_identities",
4771 "mobkit/console/inspect_identity",
4772 "mobkit/console/query_timeline",
4773 ];
4774 if can_mutate {
4775 methods.extend_from_slice(&["mobkit/retire", "mobkit/console/send"]);
4776 }
4777 if access.is_some() {
4778 methods.push("mobkit/access/status");
4779 if access_view.is_some_and(AccessView::can_administer) {
4780 methods.extend_from_slice(&["mobkit/access/get", "mobkit/access/preview"]);
4781 if can_mutate {
4782 methods.extend_from_slice(&[
4783 "mobkit/access/set",
4784 "mobkit/access/enable",
4785 "mobkit/access/rules/upsert",
4786 "mobkit/access/rules/delete",
4787 "mobkit/access/groups/set",
4788 "mobkit/access/groups/delete",
4789 ]);
4790 }
4791 }
4792 }
4793 response_value(
4794 response_id,
4795 Some(json!({
4796 "methods": methods,
4797 "authenticated": is_authenticated,
4798 "read_only": read_only,
4799 "runtime_capabilities": {
4800 "can_send_messages": can_mutate,
4801 "can_retire_members": can_mutate,
4802 "can_spawn_members": false,
4803 },
4804 "features": {
4805 "console_aggregator": console_aggregator.is_some(),
4806 "multi_runtime_console": console_aggregator.is_some(),
4807 }
4808 })),
4809 None,
4810 )
4811 }
4812 "mobkit/console/list_identities" => {
4813 let Some(aggregator) = &console_aggregator else {
4814 return console_aggregator_unavailable(response_id);
4815 };
4816 match Box::pin(aggregator.list_identities()).await {
4817 Ok(mut identities) => {
4818 retain_visible_identity_records(&mut identities, access_view);
4819 response_value(response_id, Some(json!({ "identities": identities })), None)
4820 }
4821 Err(err) => internal_error(response_id, format!("list_identities failed: {err}")),
4822 }
4823 }
4824 "mobkit/console/inspect_identity" => {
4825 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
4826 return invalid_params(response_id, "identity required");
4827 };
4828 let Some(aggregator) = &console_aggregator else {
4829 return console_aggregator_unavailable(response_id);
4830 };
4831 match Box::pin(aggregator.inspect_identity(identity)).await {
4832 Ok(Some(inspection)) => response_value(
4833 response_id,
4834 Some(serde_json::to_value(inspection).unwrap_or(Value::Null)),
4835 None,
4836 ),
4837 Ok(None) => response_value(
4838 response_id,
4839 None,
4840 Some(JsonRpcError {
4841 code: -32001,
4842 message: format!("unknown identity: {identity}"),
4843 data: None,
4844 }),
4845 ),
4846 Err(err) => internal_error(response_id, format!("inspect_identity failed: {err}")),
4847 }
4848 }
4849 "mobkit/console/query_timeline" => {
4850 let query: ConsoleTimelineWindowQuery =
4851 match serde_json::from_value(request.params.clone()) {
4852 Ok(query) => query,
4853 Err(err) => {
4854 return invalid_params(response_id, format!("invalid query params: {err}"));
4855 }
4856 };
4857 let Some(aggregator) = &console_aggregator else {
4858 return console_aggregator_unavailable(response_id);
4859 };
4860 match Box::pin(aggregator.query_timeline_windowed(query.clone())).await {
4861 Ok(mut page) => {
4862 retain_visible_timeline_frames(&mut page, access_view);
4863 response_value(
4864 response_id,
4865 Some(serde_json::to_value(page).unwrap_or(Value::Null)),
4866 None,
4867 )
4868 }
4869 Err(err) => {
4870 let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
4871 console_timeline_replay_unavailable_response(
4872 response_id,
4873 err,
4874 query.after.as_ref(),
4875 latest_cursor,
4876 )
4877 }
4878 }
4879 }
4880 "mobkit/console/send" => {
4881 let send_request: ConsoleSendRequest =
4882 match serde_json::from_value(request.params.clone()) {
4883 Ok(request) => request,
4884 Err(err) => {
4885 return invalid_params(response_id, format!("invalid send params: {err}"));
4886 }
4887 };
4888 let Some(aggregator) = &console_aggregator else {
4889 return console_aggregator_unavailable(response_id);
4890 };
4891 match Box::pin(aggregator.send(send_request)).await {
4892 Ok(accepted) => response_value(
4893 response_id,
4894 Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
4895 None,
4896 ),
4897 Err(err) => {
4898 response_value(response_id, None, Some(console_send_json_rpc_error(err)))
4899 }
4900 }
4901 }
4902 "mobkit/retire" => {
4903 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
4904 return invalid_params(response_id, "identity required");
4905 };
4906 let Some(aggregator) = &console_aggregator else {
4907 return console_aggregator_unavailable(response_id);
4908 };
4909 match Box::pin(aggregator.retire_identity(identity)).await {
4910 Ok(true) => {
4911 response_value(response_id, Some(json!({ "identity": identity })), None)
4912 }
4913 Ok(false) => response_value(
4914 response_id,
4915 None,
4916 Some(JsonRpcError {
4917 code: -32001,
4918 message: format!("unknown identity: {identity}"),
4919 data: None,
4920 }),
4921 ),
4922 Err(err) => internal_error(response_id, format!("retire failed: {err}")),
4923 }
4924 }
4925 "mobkit/reset_all" => {
4926 let Some(_aggregator) = &console_aggregator else {
4927 return console_aggregator_unavailable(response_id);
4928 };
4929 response_value(
4930 response_id,
4931 None,
4932 Some(JsonRpcError {
4933 code: -32002,
4934 message: "reset_all is not supported on the aggregator-only RPC surface"
4935 .to_string(),
4936 data: Some(json!({
4937 "kind": "unsupported_reset_all_surface",
4938 "reason": "aggregator reset_all cannot preserve baseline identity semantics",
4939 })),
4940 }),
4941 )
4942 }
4943 _ => response_value(
4944 response_id,
4945 None,
4946 Some(JsonRpcError {
4947 code: -32601,
4948 message: "Method not found".to_string(),
4949 data: None,
4950 }),
4951 ),
4952 }
4953}
4954
4955fn console_aggregator_unavailable(response_id: Value) -> Value {
4956 response_value(
4957 response_id,
4958 None,
4959 Some(JsonRpcError {
4960 code: -32004,
4961 message: "console aggregator unavailable".to_string(),
4962 data: None,
4963 }),
4964 )
4965}
4966
4967fn resolve_gating_approver_id(
4968 params: &Value,
4969 authenticated_principal: Option<&str>,
4970) -> Result<String, &'static str> {
4971 if let Some(principal) = authenticated_principal.and_then(|value| {
4972 let trimmed = value.trim();
4973 (!trimmed.is_empty()).then_some(trimmed)
4974 }) {
4975 return Ok(principal.to_string());
4976 }
4977 let Some(approver_id) = params.get("approver_id").and_then(Value::as_str) else {
4978 return Err("approver_id required");
4979 };
4980 let trimmed = approver_id.trim();
4981 if trimmed.is_empty() {
4982 return Err("approver_id required");
4983 }
4984 Ok(trimmed.to_string())
4985}
4986
4987#[allow(clippy::large_futures, clippy::too_many_arguments)]
4988#[cfg(test)]
4989async fn handle_console_runtime_rpc(
4990 runtime: &MobRuntime,
4991 module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
4992 contact_directory: Option<&ContactDirectory>,
4993 gateway_peer_keys: Option<&crate::auth::peer_keys::GatewayPeerKeys>,
4994 console_events: Option<ConsoleEventStore>,
4995 console_aggregator: Option<MobKitConsoleAggregator>,
4996 identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
4997 metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
4998 mob_events: Option<MobEventsStore>,
4999 request: JsonRpcRequest,
5000 is_authenticated: bool,
5001) -> Value {
5002 handle_console_runtime_rpc_with_visibility(
5003 runtime,
5004 module_runtime,
5005 contact_directory,
5006 gateway_peer_keys,
5007 console_events,
5008 console_aggregator,
5009 identity_runtime,
5010 metadata_table,
5011 mob_events,
5012 &crate::console_aggregator::AllowAllConsoleVisibilityPolicy,
5013 request,
5014 is_authenticated,
5015 false,
5016 None,
5017 None,
5018 None,
5019 None,
5020 None,
5021 None,
5022 None,
5023 )
5024 .await
5025}
5026
5027#[allow(clippy::too_many_arguments)]
5028async fn handle_console_runtime_rpc_with_visibility(
5029 runtime: &MobRuntime,
5030 module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
5031 contact_directory: Option<&ContactDirectory>,
5032 gateway_peer_keys: Option<&crate::auth::peer_keys::GatewayPeerKeys>,
5033 console_events: Option<ConsoleEventStore>,
5034 console_aggregator: Option<MobKitConsoleAggregator>,
5035 identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
5036 metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
5037 mob_events: Option<MobEventsStore>,
5038 visibility_policy: &dyn ConsoleVisibilityPolicy,
5039 request: JsonRpcRequest,
5040 is_authenticated: bool,
5041 read_only: bool,
5042 authenticated_principal: Option<&str>,
5043 access: Option<&AccessController>,
5044 access_view: Option<&AccessView>,
5045 memory_panel: Option<&crate::memory::sqlite_store::SqliteAgentMemoryStore>,
5046 identity_roster: Option<Arc<crate::identity_first::MutableRosterProvider>>,
5047 workgraph: Option<&meerkat::WorkGraphService>,
5048 topology: Option<&crate::topology_control::TopologyRuntimeHandle>,
5049) -> Value {
5050 let response_id = request.id.clone().unwrap_or(Value::Null);
5051 let can_mutate = is_authenticated && !read_only;
5052 if is_console_mutating_rpc_method(request.method.as_str()) && !can_mutate {
5053 return console_read_only_rpc_error(response_id);
5054 }
5055 if let Some(controller) = access.filter(|controller| controller.enabled()) {
5061 prime_access_cache_from_runtime(runtime, controller).await;
5062 }
5063 if let Some(response) = handle_access_admin_rpc(access, access_view, &request) {
5064 return response;
5065 }
5066 if let Some(error) =
5067 console_rpc_access_violation(access_view, request.method.as_str(), &request.params)
5068 {
5069 return response_value(response_id, None, Some(error));
5070 }
5071
5072 match request.method.as_str() {
5073 "mobkit/capabilities" => {
5074 let mut methods = vec![
5075 "mobkit/status",
5076 "mobkit/capabilities",
5077 "mobkit/list_members",
5078 "mobkit/get_member",
5079 "mobkit/find_members",
5080 "mobkit/member_status",
5081 "mobkit/identity/resolved_tools",
5082 "mobkit/blob/get",
5083 "mobkit/wait_ready",
5084 "mobkit/flow_status",
5085 "mobkit/list_flows",
5086 "mobkit/list_runs",
5087 "mobkit/console/list_identities",
5088 "mobkit/console/inspect_identity",
5089 "mobkit/console/query_timeline",
5090 "mobkit/mob_events/query",
5091 "mobkit/mob_events/subscribe",
5092 "mobkit/cross_mob/peer_info",
5093 "mobkit/cross_mob/directory",
5094 "mobkit/peer_pubkey",
5095 ];
5096 if identity_runtime.is_some() {
5097 methods.extend_from_slice(&["mobkit/status_identity", "mobkit/inspect_identity"]);
5098 if let Some(identity_runtime) = &identity_runtime
5099 && identity_runtime.agent_memory_supports_recall().await
5100 {
5101 methods.push("mobkit/agent_memory/recall");
5102 if can_mutate && identity_runtime.agent_memory_supports_remember().await {
5103 methods.push("mobkit/agent_memory/remember");
5104 }
5105 if can_mutate && identity_runtime.agent_memory_supports_forget().await {
5106 methods.push("mobkit/agent_memory/forget");
5107 }
5108 if can_mutate && identity_runtime.agent_memory_supports_update().await {
5109 methods.push("mobkit/agent_memory/update");
5110 }
5111 if identity_runtime.agent_memory_supports_manifest().await {
5112 methods.push("mobkit/agent_memory/manifest");
5113 }
5114 }
5115 if can_mutate {
5116 methods.push("mobkit/delete_identity");
5117 }
5118 } else if console_aggregator.is_some() {
5119 methods.extend_from_slice(&["mobkit/status_identity", "mobkit/inspect_identity"]);
5120 }
5121 if module_runtime.is_some() {
5122 methods.extend_from_slice(&[
5123 "mobkit/routing/routes/list",
5124 "mobkit/delivery/history",
5125 "mobkit/gating/pending",
5126 "mobkit/gating/audit",
5127 ]);
5128 if can_mutate {
5129 methods.push("mobkit/gating/decide");
5130 }
5131 }
5132 if can_mutate {
5133 methods.extend_from_slice(&[
5134 "mobkit/retire",
5135 "mobkit/respawn",
5142 "mobkit/reset",
5143 "mobkit/reset_all",
5144 "mobkit/console/send",
5145 "mobkit/blob/upload",
5146 "mobkit/ensure_member",
5147 "mobkit/retire_member",
5148 "mobkit/respawn_member",
5149 "mobkit/force_cancel_member",
5150 "mobkit/cancel_flow",
5151 "mobkit/collect_completed",
5152 "mobkit/run_flow",
5153 "mobkit/spawn_helper",
5154 "mobkit/fork_helper",
5155 "mobkit/attach_existing_session",
5156 "mobkit/reconcile_edges",
5157 "mobkit/cross_mob/wire_local",
5158 "mobkit/cross_mob/unwire_local",
5159 ]);
5160 }
5161 if metadata_table.is_some() {
5162 methods.extend_from_slice(&["mobkit/mob_labels/get", "mobkit/run_labels/get"]);
5163 if can_mutate {
5164 methods.extend_from_slice(&[
5165 "mobkit/mob_labels/set",
5166 "mobkit/mob_labels/delete",
5167 "mobkit/run_labels/set",
5168 "mobkit/run_labels/delete",
5169 ]);
5170 }
5171 }
5172 if memory_panel.is_some() {
5173 methods.extend_from_slice(&[
5179 "mobkit/memory/panel/records",
5180 "mobkit/memory/panel/record",
5181 "mobkit/memory/panel/dreams",
5182 "mobkit/memory/panel/dream_runs",
5183 "mobkit/memory/panel/audit_verdicts",
5184 "mobkit/memory/panel/overview",
5185 "mobkit/memory/panel/proposals",
5186 "mobkit/memory/panel/injections",
5187 "mobkit/memory/panel/harvests",
5188 "mobkit/memory/panel/quarantine",
5189 ]);
5190 }
5191 if workgraph.is_some() {
5192 methods.extend_from_slice(crate::rpc::workgraph_methods::WORKGRAPH_READ_METHODS);
5193 if can_mutate {
5194 methods
5195 .extend_from_slice(crate::rpc::workgraph_methods::WORKGRAPH_MUTATE_METHODS);
5196 methods.extend_from_slice(
5197 crate::rpc::workgraph_methods::WORKGRAPH_CONSOLE_MUTATE_METHODS,
5198 );
5199 }
5200 }
5201 if access.is_some() {
5202 methods.push("mobkit/access/status");
5203 if access_view.is_some_and(AccessView::can_administer) {
5204 methods.extend_from_slice(&["mobkit/access/get", "mobkit/access/preview"]);
5205 if can_mutate {
5206 methods.extend_from_slice(&[
5207 "mobkit/access/set",
5208 "mobkit/access/enable",
5209 "mobkit/access/rules/upsert",
5210 "mobkit/access/rules/delete",
5211 "mobkit/access/groups/set",
5212 "mobkit/access/groups/delete",
5213 ]);
5214 }
5215 }
5216 }
5217 let topology_capabilities = topology.map(|topology| {
5218 let (topology_methods, capabilities) =
5219 crate::rpc::topology_methods::capability_projection(
5220 topology,
5221 access_view,
5222 !can_mutate,
5223 );
5224 methods.extend(topology_methods);
5225 capabilities
5226 });
5227 if let Some(view) = access_view.filter(|view| view.enforced()) {
5235 let probe = serde_json::json!({ "identity": "\u{0}cap-probe" });
5236 methods.retain(
5237 |method| match console_rpc_access_requirements(method, &probe) {
5238 Some(requirements) => requirements
5239 .iter()
5240 .all(|(action, target)| target.is_some() || view.allows(action)),
5241 None => true,
5242 },
5243 );
5244 }
5245 let cap = |action: &str| -> bool {
5248 can_mutate
5249 && access_view
5250 .filter(|view| view.enforced())
5251 .is_none_or(|view| view.may_perform_anywhere(action))
5252 };
5253 response_value(
5254 response_id,
5255 Some(serde_json::json!({
5256 "contract_version": crate::rpc::MOBKIT_CONTRACT_VERSION,
5257 "methods": methods,
5258 "identity_first": identity_runtime.is_some(),
5264 "workgraph": workgraph.is_some(),
5267 "read_only": read_only,
5268 "loaded_modules": serde_json::json!([]),
5271 "runtime_capabilities": {
5272 "can_send_messages": cap(ACTION_AGENT_SEND),
5273 "can_retire_members": cap(ACTION_AGENT_RETIRE),
5274 "can_spawn_members": cap(ACTION_AGENT_SPAWN),
5275 },
5276 "topology_control": topology_capabilities,
5277 })),
5278 None,
5279 )
5280 }
5281 crate::rpc::topology_methods::TOPOLOGY_QUERY_METHOD => {
5282 let Some(topology) = topology else {
5283 return response_value(
5284 response_id,
5285 None,
5286 Some(JsonRpcError {
5287 code: -32601,
5288 message: "Method not found".to_string(),
5289 data: None,
5290 }),
5291 );
5292 };
5293 serde_json::to_value(
5294 crate::rpc::topology_methods::handle_query(
5295 topology,
5296 response_id,
5297 access_view,
5298 !can_mutate,
5299 )
5300 .await,
5301 )
5302 .unwrap_or(Value::Null)
5303 }
5304 crate::rpc::topology_methods::TOPOLOGY_PLAN_METHOD => {
5305 let Some(topology) = topology else {
5306 return response_value(
5307 response_id,
5308 None,
5309 Some(JsonRpcError {
5310 code: -32601,
5311 message: "Method not found".to_string(),
5312 data: None,
5313 }),
5314 );
5315 };
5316 serde_json::to_value(
5317 crate::rpc::topology_methods::handle_plan(
5318 topology,
5319 response_id,
5320 &request.params,
5321 access_view,
5322 )
5323 .await,
5324 )
5325 .unwrap_or(Value::Null)
5326 }
5327 crate::rpc::topology_methods::TOPOLOGY_APPLY_METHOD => {
5328 let Some(topology) = topology else {
5329 return response_value(
5330 response_id,
5331 None,
5332 Some(JsonRpcError {
5333 code: -32601,
5334 message: "Method not found".to_string(),
5335 data: None,
5336 }),
5337 );
5338 };
5339 serde_json::to_value(
5340 crate::rpc::topology_methods::handle_apply(
5341 topology,
5342 response_id,
5343 &request.params,
5344 access_view,
5345 authenticated_principal,
5346 )
5347 .await,
5348 )
5349 .unwrap_or(Value::Null)
5350 }
5351 crate::rpc::topology_methods::TOPOLOGY_OPERATION_METHOD => {
5352 let Some(topology) = topology else {
5353 return response_value(
5354 response_id,
5355 None,
5356 Some(JsonRpcError {
5357 code: -32601,
5358 message: "Method not found".to_string(),
5359 data: None,
5360 }),
5361 );
5362 };
5363 serde_json::to_value(
5364 crate::rpc::topology_methods::handle_operation(
5365 topology,
5366 response_id,
5367 &request.params,
5368 access_view,
5369 )
5370 .await,
5371 )
5372 .unwrap_or(Value::Null)
5373 }
5374 crate::rpc::topology_methods::TOPOLOGY_AUDIT_METHOD => {
5375 let Some(topology) = topology else {
5376 return response_value(
5377 response_id,
5378 None,
5379 Some(JsonRpcError {
5380 code: -32601,
5381 message: "Method not found".to_string(),
5382 data: None,
5383 }),
5384 );
5385 };
5386 serde_json::to_value(
5387 crate::rpc::topology_methods::handle_audit(
5388 topology,
5389 response_id,
5390 &request.params,
5391 access_view,
5392 )
5393 .await,
5394 )
5395 .unwrap_or(Value::Null)
5396 }
5397 "mobkit/agent_memory/remember" => {
5398 let Some(identity_runtime) = &identity_runtime else {
5399 return response_value(
5400 response_id,
5401 None,
5402 Some(JsonRpcError {
5403 code: -32601,
5404 message: "agent memory is not configured".to_string(),
5405 data: None,
5406 }),
5407 );
5408 };
5409 match parse_agent_memory_remember_params(&request.params) {
5410 Ok(remember_request) => match identity_runtime
5411 .remember_agent_memory(
5412 &remember_request.realm,
5413 &remember_request.identity,
5414 remember_request.memory,
5415 )
5416 .await
5417 {
5418 Ok(record) => response_value(
5419 response_id,
5420 Some(serde_json::to_value(record).unwrap_or(Value::Null)),
5421 None,
5422 ),
5423 Err(err) => response_value(
5424 response_id,
5425 None,
5426 Some(crate::rpc::agent_memory_rpc_error("write", err)),
5427 ),
5428 },
5429 Err(err) => {
5430 invalid_params(response_id, format!("Invalid params: {}", err.message()))
5431 }
5432 }
5433 }
5434 "mobkit/agent_memory/forget" => {
5435 let Some(identity_runtime) = &identity_runtime else {
5436 return response_value(
5437 response_id,
5438 None,
5439 Some(JsonRpcError {
5440 code: -32601,
5441 message: "agent memory is not configured".to_string(),
5442 data: None,
5443 }),
5444 );
5445 };
5446 match parse_agent_memory_forget_params(&request.params) {
5447 Ok(forget_request) => match identity_runtime
5448 .forget_agent_memory(
5449 &forget_request.realm,
5450 &forget_request.identity,
5451 &forget_request.memory_id,
5452 )
5453 .await
5454 {
5455 Ok(result) => response_value(
5456 response_id,
5457 Some(serde_json::to_value(result).unwrap_or(Value::Null)),
5458 None,
5459 ),
5460 Err(err) => response_value(
5461 response_id,
5462 None,
5463 Some(crate::rpc::agent_memory_rpc_error("forget", err)),
5464 ),
5465 },
5466 Err(err) => {
5467 invalid_params(response_id, format!("Invalid params: {}", err.message()))
5468 }
5469 }
5470 }
5471 "mobkit/agent_memory/recall" => {
5472 let Some(identity_runtime) = &identity_runtime else {
5473 return response_value(
5474 response_id,
5475 None,
5476 Some(JsonRpcError {
5477 code: -32601,
5478 message: "agent memory is not configured".to_string(),
5479 data: None,
5480 }),
5481 );
5482 };
5483 match parse_agent_memory_recall_params(&request.params) {
5484 Ok(recall_request) => match identity_runtime
5485 .recall_agent_memory(recall_request.request)
5486 .await
5487 {
5488 Ok(records) => {
5489 response_value(response_id, Some(json!({ "records": records })), None)
5490 }
5491 Err(err) => response_value(
5492 response_id,
5493 None,
5494 Some(crate::rpc::agent_memory_rpc_error("recall", err)),
5495 ),
5496 },
5497 Err(err) => {
5498 invalid_params(response_id, format!("Invalid params: {}", err.message()))
5499 }
5500 }
5501 }
5502 "mobkit/agent_memory/update" => {
5503 let Some(identity_runtime) = &identity_runtime else {
5504 return response_value(
5505 response_id,
5506 None,
5507 Some(JsonRpcError {
5508 code: -32601,
5509 message: "agent memory is not configured".to_string(),
5510 data: None,
5511 }),
5512 );
5513 };
5514 match parse_agent_memory_update_params(&request.params) {
5515 Ok(update_request) => match identity_runtime
5516 .update_agent_memory(
5517 &update_request.realm,
5518 &update_request.identity,
5519 &update_request.memory_id,
5520 update_request.memory,
5521 )
5522 .await
5523 {
5524 Ok(new_id) => response_value(
5525 response_id,
5526 Some(json!({
5527 "memory_id": new_id,
5528 "supersedes": update_request.memory_id,
5529 })),
5530 None,
5531 ),
5532 Err(err) => response_value(
5533 response_id,
5534 None,
5535 Some(crate::rpc::agent_memory_rpc_error("update", err)),
5536 ),
5537 },
5538 Err(err) => {
5539 invalid_params(response_id, format!("Invalid params: {}", err.message()))
5540 }
5541 }
5542 }
5543 "mobkit/agent_memory/manifest" => {
5544 let Some(identity_runtime) = &identity_runtime else {
5545 return response_value(
5546 response_id,
5547 None,
5548 Some(JsonRpcError {
5549 code: -32601,
5550 message: "agent memory is not configured".to_string(),
5551 data: None,
5552 }),
5553 );
5554 };
5555 match parse_agent_memory_manifest_params(&request.params) {
5556 Ok(manifest_request) => match identity_runtime
5557 .manifest_agent_memory(
5558 &manifest_request.realm,
5559 &manifest_request.identity,
5560 manifest_request.tier,
5561 )
5562 .await
5563 {
5564 Ok(records) => {
5565 response_value(response_id, Some(json!({ "records": records })), None)
5566 }
5567 Err(err) => response_value(
5568 response_id,
5569 None,
5570 Some(crate::rpc::agent_memory_rpc_error("manifest", err)),
5571 ),
5572 },
5573 Err(err) => {
5574 invalid_params(response_id, format!("Invalid params: {}", err.message()))
5575 }
5576 }
5577 }
5578 "mobkit/memory/panel/records" => {
5583 handle_memory_panel_records(memory_panel, access_view, &request.params, response_id)
5584 .await
5585 }
5586 "mobkit/memory/panel/record" => {
5587 handle_memory_panel_record(memory_panel, access_view, &request.params, response_id)
5588 .await
5589 }
5590 "mobkit/memory/panel/quarantine" => {
5591 handle_memory_panel_quarantine(memory_panel, access_view, &request.params, response_id)
5592 .await
5593 }
5594 "mobkit/memory/panel/dreams" => {
5595 handle_memory_panel_dreams(memory_panel, &request.params, response_id).await
5596 }
5597 "mobkit/memory/panel/dream_runs" => {
5598 handle_memory_panel_dream_runs(memory_panel, &request.params, response_id).await
5599 }
5600 "mobkit/memory/panel/audit_verdicts" => {
5601 handle_memory_panel_audit_verdicts(memory_panel, &request.params, response_id).await
5602 }
5603 "mobkit/memory/panel/overview" => {
5604 handle_memory_panel_overview(memory_panel, &request.params, response_id).await
5605 }
5606 "mobkit/memory/panel/proposals" => {
5607 handle_memory_panel_proposals(memory_panel, &request.params, response_id).await
5608 }
5609 "mobkit/memory/panel/injections" => {
5610 handle_memory_panel_injections(memory_panel, &request.params, response_id).await
5611 }
5612 "mobkit/memory/panel/harvests" => {
5613 handle_memory_panel_harvests(memory_panel, &request.params, response_id).await
5614 }
5615 "mobkit/status" => {
5616 let mob_state = runtime.handle().status_observation_snapshot();
5617 response_value(
5618 response_id,
5619 Some(serde_json::json!({
5620 "contract_version": crate::rpc::MOBKIT_CONTRACT_VERSION,
5621 "running": matches!(mob_state, MobState::Creating | MobState::Running),
5622 "loaded_modules": serde_json::json!([]),
5625 })),
5626 None,
5627 )
5628 }
5629 "mobkit/console/list_identities" => {
5630 let Some(aggregator) = &console_aggregator else {
5631 return response_value(
5632 response_id,
5633 None,
5634 Some(JsonRpcError {
5635 code: -32004,
5636 message: "console aggregator unavailable".to_string(),
5637 data: None,
5638 }),
5639 );
5640 };
5641 match Box::pin(aggregator.list_identities()).await {
5642 Ok(mut identities) => {
5643 retain_visible_identity_records(&mut identities, access_view);
5644 response_value(response_id, Some(json!({ "identities": identities })), None)
5645 }
5646 Err(err) => internal_error(response_id, format!("list_identities failed: {err}")),
5647 }
5648 }
5649 "mobkit/console/inspect_identity" => {
5650 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5651 return invalid_params(response_id, "identity required");
5652 };
5653 let Some(aggregator) = &console_aggregator else {
5654 return response_value(
5655 response_id,
5656 None,
5657 Some(JsonRpcError {
5658 code: -32004,
5659 message: "console aggregator unavailable".to_string(),
5660 data: None,
5661 }),
5662 );
5663 };
5664 match Box::pin(aggregator.inspect_identity(identity)).await {
5665 Ok(Some(inspection)) => response_value(
5666 response_id,
5667 Some(serde_json::to_value(inspection).unwrap_or(Value::Null)),
5668 None,
5669 ),
5670 Ok(None) => response_value(
5671 response_id,
5672 None,
5673 Some(JsonRpcError {
5674 code: -32001,
5675 message: format!("unknown identity: {identity}"),
5676 data: None,
5677 }),
5678 ),
5679 Err(err) => internal_error(response_id, format!("inspect_identity failed: {err}")),
5680 }
5681 }
5682 "mobkit/console/query_timeline" => {
5683 let query: ConsoleTimelineWindowQuery =
5684 match serde_json::from_value(request.params.clone()) {
5685 Ok(query) => query,
5686 Err(err) => {
5687 return invalid_params(response_id, format!("invalid query params: {err}"));
5688 }
5689 };
5690 let Some(aggregator) = &console_aggregator else {
5691 return response_value(
5692 response_id,
5693 None,
5694 Some(JsonRpcError {
5695 code: -32004,
5696 message: "console aggregator unavailable".to_string(),
5697 data: None,
5698 }),
5699 );
5700 };
5701 match Box::pin(aggregator.query_timeline_windowed(query.clone())).await {
5702 Ok(mut page) => {
5703 retain_visible_timeline_frames(&mut page, access_view);
5704 response_value(
5705 response_id,
5706 Some(serde_json::to_value(page).unwrap_or(Value::Null)),
5707 None,
5708 )
5709 }
5710 Err(err) => {
5711 let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
5712 console_timeline_replay_unavailable_response(
5713 response_id,
5714 err,
5715 query.after.as_ref(),
5716 latest_cursor,
5717 )
5718 }
5719 }
5720 }
5721 "mobkit/console/send" => {
5722 let send_request: ConsoleSendRequest =
5723 match serde_json::from_value(request.params.clone()) {
5724 Ok(request) => request,
5725 Err(err) => {
5726 return invalid_params(response_id, format!("invalid send params: {err}"));
5727 }
5728 };
5729 let Some(aggregator) = &console_aggregator else {
5730 return response_value(
5731 response_id,
5732 None,
5733 Some(JsonRpcError {
5734 code: -32004,
5735 message: "console aggregator unavailable".to_string(),
5736 data: None,
5737 }),
5738 );
5739 };
5740 if let Some(identity_runtime) = &identity_runtime {
5741 return match Box::pin(console_send_with_identity_first_fallback(
5742 aggregator,
5743 identity_runtime.clone(),
5744 Some(runtime),
5745 console_events.as_ref(),
5746 send_request,
5747 ))
5748 .await
5749 {
5750 Ok(accepted) => response_value(
5751 response_id,
5752 Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
5753 None,
5754 ),
5755 Err(err) => {
5756 response_value(response_id, None, Some(console_send_json_rpc_error(err)))
5757 }
5758 };
5759 }
5760 match Box::pin(aggregator.send(send_request)).await {
5761 Ok(accepted) => response_value(
5762 response_id,
5763 Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
5764 None,
5765 ),
5766 Err(err) => {
5767 response_value(response_id, None, Some(console_send_json_rpc_error(err)))
5768 }
5769 }
5770 }
5771 "mobkit/blob/get" => {
5772 let Some(blob_id) = request
5773 .params
5774 .get("blob_id")
5775 .or_else(|| request.params.get("id"))
5776 .and_then(Value::as_str)
5777 else {
5778 return invalid_params(response_id, "blob_id required");
5779 };
5780 if !is_valid_blob_id_value(blob_id) {
5781 return invalid_params(response_id, "invalid blob_id");
5782 }
5783 let Some(store) = runtime.binary_blob_store() else {
5784 return internal_error(response_id, "binary blob store unavailable");
5785 };
5786 match store.get_bytes(&meerkat_core::BlobId::from(blob_id)).await {
5787 Ok(payload) => response_value(
5788 response_id,
5789 Some(serde_json::json!({
5790 "blob_id": payload.blob_id,
5791 "media_type": payload.media_type,
5792 "size": payload.size,
5793 "data": base64::engine::general_purpose::STANDARD.encode(payload.data.as_ref()),
5794 })),
5795 None,
5796 ),
5797 Err(meerkat_core::BlobStoreError::NotFound(_)) => response_value(
5798 response_id,
5799 None,
5800 Some(JsonRpcError {
5801 code: -32001,
5802 message: format!("blob not found: {blob_id}"),
5803 data: Some(json!({ "kind": "not_found", "blob_id": blob_id })),
5804 }),
5805 ),
5806 Err(err) => internal_error(response_id, format!("blob get failed: {err}")),
5807 }
5808 }
5809 "mobkit/list_members" => {
5810 let handle = runtime.handle();
5811 let entries = handle.list_members_including_retiring().await;
5812 let mut members = Vec::with_capacity(entries.len());
5813 for entry in &entries {
5814 let alias =
5815 crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str());
5816 if stale_runtime_alias_json_rpc_error(
5817 "list_members",
5818 identity_runtime.as_ref(),
5819 alias.as_ref(),
5820 )
5821 .await
5822 .is_some()
5823 {
5824 continue;
5825 }
5826 members.push(member_entry_to_console_json(runtime, entry).await);
5827 }
5828 retain_visible_member_rows(&mut members, access_view);
5829 response_value(response_id, Some(Value::Array(members)), None)
5830 }
5831 "mobkit/get_member" => {
5832 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
5833 return invalid_params(response_id, "member_id required");
5834 };
5835 if let Some(error) = stale_runtime_alias_json_rpc_error(
5836 "get_member",
5837 identity_runtime.as_ref(),
5838 member_id,
5839 )
5840 .await
5841 {
5842 return response_value(response_id, None, Some(error));
5843 }
5844 let handle = runtime.handle();
5845 let identity = crate::member_comms_id::mob_member_id(member_id);
5846 let entries = handle.list_members_including_retiring().await;
5847 match entries.into_iter().find(|e| e.agent_identity == identity) {
5848 Some(entry) => response_value(
5849 response_id,
5850 Some(member_entry_to_console_json(runtime, &entry).await),
5851 None,
5852 ),
5853 None => invalid_params(response_id, format!("member not found: {member_id}")),
5854 }
5855 }
5856 "mobkit/find_members" => {
5857 let Some(label_key) = request.params.get("label_key").and_then(Value::as_str) else {
5858 return invalid_params(response_id, "label_key required");
5859 };
5860 let Some(label_value) = request.params.get("label_value").and_then(Value::as_str)
5861 else {
5862 return invalid_params(response_id, "label_value required");
5863 };
5864 let handle = runtime.handle();
5865 let filter = MemberFilter {
5866 labels: std::collections::BTreeMap::from([(
5867 label_key.to_string(),
5868 label_value.to_string(),
5869 )]),
5870 role: None,
5871 status: None,
5872 };
5873 let entries = match handle.list_members_matching(filter).await {
5874 Ok(entries) => entries,
5875 Err(err) => {
5876 return invalid_params(response_id, format!("member lookup failed: {err}"));
5877 }
5878 };
5879 let mut matches = Vec::with_capacity(entries.len());
5880 for entry in &entries {
5881 let alias =
5882 crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str());
5883 if stale_runtime_alias_json_rpc_error(
5884 "find_members",
5885 identity_runtime.as_ref(),
5886 alias.as_ref(),
5887 )
5888 .await
5889 .is_some()
5890 {
5891 continue;
5892 }
5893 matches.push(member_entry_to_console_json(runtime, entry).await);
5894 }
5895 retain_visible_member_rows(&mut matches, access_view);
5896 response_value(response_id, Some(Value::Array(matches)), None)
5897 }
5898 "mobkit/status_identity" => {
5899 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5900 return invalid_params(response_id, "identity required");
5901 };
5902 let handle = runtime.handle();
5903 if let Some(identity_runtime) = &identity_runtime {
5904 let (parsed_identity, _requested_exact_identity, live_alias) =
5905 match resolve_console_identity_control_target(
5906 &handle,
5907 Some(identity_runtime),
5908 visibility_policy,
5909 identity,
5910 )
5911 .await
5912 {
5913 Ok(Some(target)) => target,
5914 Ok(None) => {
5915 return invalid_params(
5916 response_id,
5917 format!("identity not found: {identity}"),
5918 );
5919 }
5920 Err(err) => return response_value(response_id, None, Some(err)),
5921 };
5922 match identity_runtime.status(&parsed_identity).await {
5923 Ok(status) => {
5924 if !identity_status_visible_to_console(visibility_policy, &status) {
5925 return identity_hidden_by_policy_response(response_id, identity);
5926 }
5927 let phase = if let Some(store) = &console_events {
5928 store
5929 .response_phase_for_identity(status.identity.as_str())
5930 .await
5931 } else {
5932 None
5933 };
5934 if let Some(error) = stale_live_alias_json_rpc_error(
5935 "status_identity",
5936 identity_runtime,
5937 &parsed_identity,
5938 live_alias.as_ref(),
5939 )
5940 .await
5941 {
5942 return response_value(response_id, None, Some(error));
5943 }
5944 return response_value(
5945 response_id,
5946 Some(console_identity_status_json_from_identity_status(
5947 &status, phase,
5948 )),
5949 None,
5950 );
5951 }
5952 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
5953 Err(err) => {
5954 return console_identity_error_response(
5955 response_id,
5956 "status_identity",
5957 err,
5958 );
5959 }
5960 }
5961 }
5962 if let Some(aggregator) = &console_aggregator {
5963 return match Box::pin(aggregator.inspect_identity(identity)).await {
5964 Ok(Some(inspection)) => {
5965 let phase = if let Some(store) = &console_events {
5966 store
5967 .response_phase_for_identity(&inspection.identity.identity)
5968 .await
5969 } else {
5970 None
5971 };
5972 response_value(
5973 response_id,
5974 Some(console_identity_status_json_from_record(
5975 &inspection.identity,
5976 phase,
5977 )),
5978 None,
5979 )
5980 }
5981 Ok(None) => response_value(
5982 response_id,
5983 None,
5984 Some(JsonRpcError {
5985 code: -32001,
5986 message: format!("unknown identity: {identity}"),
5987 data: None,
5988 }),
5989 ),
5990 Err(err) => {
5991 internal_error(response_id, format!("status_identity failed: {err}"))
5992 }
5993 };
5994 }
5995 let live_alias = match lookup_member_alias_with_session(
5996 &handle,
5997 visibility_policy,
5998 identity,
5999 )
6000 .await
6001 {
6002 Ok(alias) => alias,
6003 Err(err) => return response_value(response_id, None, Some(err)),
6004 };
6005 let Some(alias) = live_alias else {
6006 return invalid_params(response_id, format!("identity not found: {identity}"));
6007 };
6008 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6009 return identity_hidden_by_policy_response(response_id, identity);
6010 }
6011 if let Err(err) =
6012 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
6013 {
6014 return response_value(response_id, None, Some(err));
6015 }
6016 let phase = if let Some(store) = &console_events {
6017 store.response_phase_for_identity(&alias.identity).await
6018 } else {
6019 None
6020 };
6021 response_value(
6022 response_id,
6023 Some(console_identity_status_json_for_identity(
6024 &alias.identity,
6025 &alias.member,
6026 alias.session_id,
6027 phase,
6028 )),
6029 None,
6030 )
6031 }
6032 "mobkit/inspect_identity" => {
6033 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6034 return invalid_params(response_id, "identity required");
6035 };
6036 let handle = runtime.handle();
6037 if let Some(identity_runtime) = &identity_runtime {
6038 let (parsed_identity, _requested_exact_identity, live_alias) =
6039 match resolve_console_identity_control_target(
6040 &handle,
6041 Some(identity_runtime),
6042 visibility_policy,
6043 identity,
6044 )
6045 .await
6046 {
6047 Ok(Some(target)) => target,
6048 Ok(None) => {
6049 return invalid_params(
6050 response_id,
6051 format!("identity not found: {identity}"),
6052 );
6053 }
6054 Err(err) => return response_value(response_id, None, Some(err)),
6055 };
6056 match identity_runtime.status(&parsed_identity).await {
6057 Ok(status) => {
6058 if !identity_status_visible_to_console(visibility_policy, &status) {
6059 return identity_hidden_by_policy_response(response_id, identity);
6060 }
6061 let phase = if let Some(store) = &console_events {
6062 store
6063 .response_phase_for_identity(status.identity.as_str())
6064 .await
6065 } else {
6066 None
6067 };
6068 if let Some(error) = stale_live_alias_json_rpc_error(
6069 "inspect_identity",
6070 identity_runtime,
6071 &parsed_identity,
6072 live_alias.as_ref(),
6073 )
6074 .await
6075 {
6076 return response_value(response_id, None, Some(error));
6077 }
6078 return response_value(
6079 response_id,
6080 Some(console_identity_inspect_json_from_identity_status(
6081 &status,
6082 live_alias.as_ref(),
6083 phase,
6084 )),
6085 None,
6086 );
6087 }
6088 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
6089 Err(err) => {
6090 return console_identity_error_response(
6091 response_id,
6092 "inspect_identity",
6093 err,
6094 );
6095 }
6096 }
6097 }
6098 if let Some(aggregator) = &console_aggregator {
6099 return match Box::pin(aggregator.inspect_identity(identity)).await {
6100 Ok(Some(inspection)) => {
6101 let phase = if let Some(store) = &console_events {
6102 store
6103 .response_phase_for_identity(&inspection.identity.identity)
6104 .await
6105 } else {
6106 None
6107 };
6108 response_value(
6109 response_id,
6110 Some(console_identity_inspect_json_from_record(
6111 &inspection,
6112 phase,
6113 )),
6114 None,
6115 )
6116 }
6117 Ok(None) => response_value(
6118 response_id,
6119 None,
6120 Some(JsonRpcError {
6121 code: -32001,
6122 message: format!("unknown identity: {identity}"),
6123 data: None,
6124 }),
6125 ),
6126 Err(err) => {
6127 internal_error(response_id, format!("inspect_identity failed: {err}"))
6128 }
6129 };
6130 }
6131 let live_alias = match lookup_member_alias_with_session(
6132 &handle,
6133 visibility_policy,
6134 identity,
6135 )
6136 .await
6137 {
6138 Ok(alias) => alias,
6139 Err(err) => return response_value(response_id, None, Some(err)),
6140 };
6141 let Some(alias) = live_alias else {
6142 return invalid_params(response_id, format!("identity not found: {identity}"));
6143 };
6144 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6145 return identity_hidden_by_policy_response(response_id, identity);
6146 }
6147 if let Err(err) =
6148 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
6149 {
6150 return response_value(response_id, None, Some(err));
6151 }
6152 let phase = if let Some(store) = &console_events {
6153 store.response_phase_for_identity(&alias.identity).await
6154 } else {
6155 None
6156 };
6157 response_value(
6158 response_id,
6159 Some(console_identity_inspect_json_for_identity(
6160 &alias.identity,
6161 &alias.member,
6162 alias.session_id,
6163 phase,
6164 )),
6165 None,
6166 )
6167 }
6168 "mobkit/retire" => {
6169 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6170 return invalid_params(response_id, "identity required");
6171 };
6172 let handle = runtime.handle();
6173 if let Some(identity_runtime) = &identity_runtime {
6174 let (parsed_identity, _requested_exact_identity, live_alias) =
6175 match resolve_console_identity_control_target(
6176 &handle,
6177 Some(identity_runtime),
6178 visibility_policy,
6179 identity,
6180 )
6181 .await
6182 {
6183 Ok(Some(target)) => target,
6184 Ok(None) => {
6185 return invalid_params(
6186 response_id,
6187 format!("identity not found: {identity}"),
6188 );
6189 }
6190 Err(err) => return response_value(response_id, None, Some(err)),
6191 };
6192 let registered_status = match identity_runtime.status(&parsed_identity).await {
6193 Ok(status) => Some(status),
6194 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => None,
6195 Err(err) => {
6196 return console_identity_error_response(response_id, "retire", err);
6197 }
6198 };
6199 if let Some(status) = registered_status.as_ref() {
6200 if !identity_status_visible_to_console(visibility_policy, status) {
6201 return identity_hidden_by_policy_response(response_id, identity);
6202 }
6203 if let Some(error) = stale_live_alias_json_rpc_error(
6204 "retire",
6205 identity_runtime,
6206 &parsed_identity,
6207 live_alias.as_ref(),
6208 )
6209 .await
6210 {
6211 return response_value(response_id, None, Some(error));
6212 }
6213 }
6214 match identity_runtime.retire(&parsed_identity).await {
6215 Ok(token) => {
6216 let keep_runtime_member_id = registered_status
6217 .as_ref()
6218 .and_then(|status| status.agent_runtime_id.as_ref())
6219 .filter(|_| identity_runtime.has_session_bridge())
6220 .map(crate::identity_first::AgentRuntimeId::as_str);
6221 let cleanup_warning = if registered_status.is_some()
6222 && let Err(err) = retire_stale_console_members_for_identity(
6223 &handle,
6224 visibility_policy,
6225 parsed_identity.as_str(),
6226 keep_runtime_member_id,
6227 )
6228 .await
6229 {
6230 Some(json!({
6231 "kind": "stale_member_cleanup_failed_after_identity_retire",
6232 "identity": parsed_identity.as_str(),
6233 "message": err,
6234 }))
6235 } else {
6236 None
6237 };
6238 if let Some(store) = &console_events {
6239 store
6240 .record_lifecycle(
6241 parsed_identity.as_str(),
6242 "identity_retired",
6243 json!({
6244 "fencing_token": token.get(),
6245 "cleanup_warning": cleanup_warning.clone(),
6246 }),
6247 )
6248 .await;
6249 }
6250 return response_value(
6251 response_id,
6252 Some(json!({
6253 "identity": parsed_identity.as_str(),
6254 "fencing_token": token.get(),
6255 "cleanup_warning": cleanup_warning,
6256 })),
6257 None,
6258 );
6259 }
6260 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6261 if let Some(alias) = live_alias.as_ref() {
6262 if !runtime_alias_visible_to_console(&handle, visibility_policy, alias)
6263 {
6264 return identity_hidden_by_policy_response(response_id, identity);
6265 }
6266 let mid = crate::member_comms_id::mob_member_id(
6267 alias.runtime_member_id.as_str(),
6268 );
6269 return match retire_console_member(&handle, &mid).await {
6270 Ok(()) => {
6271 if let Some(store) = &console_events {
6272 store
6273 .record_lifecycle(
6274 &alias.identity,
6275 "identity_retired",
6276 json!({}),
6277 )
6278 .await;
6279 }
6280 response_value(
6281 response_id,
6282 Some(json!({ "identity": alias.identity })),
6283 None,
6284 )
6285 }
6286 Err(err) => {
6287 internal_error(response_id, format!("retire failed: {err}"))
6288 }
6289 };
6290 }
6291 }
6292 Err(err) => return console_identity_error_response(response_id, "retire", err),
6293 }
6294 }
6295 if let Some(aggregator) = &console_aggregator {
6296 let canonical_identity = match Box::pin(aggregator.inspect_identity(identity)).await
6297 {
6298 Ok(Some(inspection)) => inspection.identity.identity,
6299 Ok(None) => identity.to_string(),
6300 Err(_) => identity.to_string(),
6301 };
6302 return match Box::pin(aggregator.retire_identity(identity)).await {
6303 Ok(true) => {
6304 if let Some(store) = &console_events {
6305 store
6306 .record_lifecycle(
6307 &canonical_identity,
6308 "identity_retired",
6309 json!({}),
6310 )
6311 .await;
6312 }
6313 response_value(
6314 response_id,
6315 Some(json!({ "identity": canonical_identity })),
6316 None,
6317 )
6318 }
6319 Ok(false) => response_value(
6320 response_id,
6321 None,
6322 Some(JsonRpcError {
6323 code: -32001,
6324 message: format!("unknown identity: {identity}"),
6325 data: None,
6326 }),
6327 ),
6328 Err(err) => internal_error(response_id, format!("retire failed: {err}")),
6329 };
6330 }
6331 let live_alias = match lookup_member_alias_with_session(
6332 &handle,
6333 visibility_policy,
6334 identity,
6335 )
6336 .await
6337 {
6338 Ok(alias) => alias,
6339 Err(err) => return response_value(response_id, None, Some(err)),
6340 };
6341 let Some(alias) = live_alias else {
6342 return invalid_params(response_id, format!("identity not found: {identity}"));
6343 };
6344 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6345 return identity_hidden_by_policy_response(response_id, identity);
6346 }
6347 if let Err(err) =
6348 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
6349 {
6350 return response_value(response_id, None, Some(err));
6351 }
6352 let mid = crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str());
6353 match retire_console_member(&handle, &mid).await {
6354 Ok(()) => {
6355 if let Some(store) = &console_events {
6356 store
6357 .record_lifecycle(&alias.identity, "identity_retired", json!({}))
6358 .await;
6359 }
6360 response_value(
6361 response_id,
6362 Some(json!({ "identity": alias.identity })),
6363 None,
6364 )
6365 }
6366 Err(err) => internal_error(response_id, format!("retire failed: {err}")),
6367 }
6368 }
6369 "mobkit/respawn" => {
6370 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6371 return invalid_params(response_id, "identity required");
6372 };
6373 let handle = runtime.handle();
6374 if let Some(identity_runtime) = &identity_runtime {
6375 let (parsed_identity, _requested_exact_identity, live_alias) =
6376 match resolve_console_identity_control_target(
6377 &handle,
6378 Some(identity_runtime),
6379 visibility_policy,
6380 identity,
6381 )
6382 .await
6383 {
6384 Ok(Some(target)) => target,
6385 Ok(None) => {
6386 return invalid_params(
6387 response_id,
6388 format!("identity not found: {identity}"),
6389 );
6390 }
6391 Err(err) => return response_value(response_id, None, Some(err)),
6392 };
6393 let registered_status = match identity_runtime.status(&parsed_identity).await {
6394 Ok(status) => Some(status),
6395 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => None,
6396 Err(err) => {
6397 return console_identity_error_response(response_id, "respawn", err);
6398 }
6399 };
6400 if let Some(status) = registered_status.as_ref() {
6401 if !identity_status_visible_to_console(visibility_policy, status) {
6402 return identity_hidden_by_policy_response(response_id, identity);
6403 }
6404 if let Some(error) = stale_live_alias_json_rpc_error(
6405 "respawn",
6406 identity_runtime,
6407 &parsed_identity,
6408 live_alias.as_ref(),
6409 )
6410 .await
6411 {
6412 return response_value(response_id, None, Some(error));
6413 }
6414 }
6415 match identity_runtime.respawn(&parsed_identity).await {
6416 Ok(mut record) => {
6417 let live_respawn_warning = match Box::pin(respawn_console_member(
6418 &handle,
6419 &crate::member_comms_id::mob_member_id(
6420 record.agent_runtime_id.as_str(),
6421 ),
6422 ))
6423 .await
6424 {
6425 Ok(topology_restore_warning) => {
6426 let live_session_id = handle
6427 .resolve_bridge_session_id_observation(
6428 &crate::member_comms_id::mob_member_id(
6429 record.agent_runtime_id.as_str(),
6430 ),
6431 )
6432 .await;
6433 if let Some(live_session_id) = live_session_id {
6434 match identity_runtime
6435 .rebind_session_after_live_respawn(
6436 &parsed_identity,
6437 live_session_id.clone(),
6438 )
6439 .await
6440 {
6441 Ok(updated_record) => {
6442 record = updated_record;
6443 topology_restore_warning
6444 }
6445 Err(err) => Some(json!({
6446 "kind": "identity_rebind_failed_after_member_respawn",
6447 "identity": record.identity.as_str(),
6448 "agent_runtime_id": record.agent_runtime_id.as_str(),
6449 "live_session_id": live_session_id.to_string(),
6450 "message": err.to_string(),
6451 })),
6452 }
6453 } else {
6454 topology_restore_warning
6455 }
6456 }
6457 Err(err) => Some(json!({
6458 "kind": "member_respawn_failed_after_identity_refresh",
6459 "identity": record.identity.as_str(),
6460 "agent_runtime_id": record.agent_runtime_id.as_str(),
6461 "message": err,
6462 })),
6463 };
6464 let cleanup_warning = if registered_status.is_some()
6465 && let Err(err) = retire_stale_console_members_for_identity(
6466 &handle,
6467 visibility_policy,
6468 parsed_identity.as_str(),
6469 Some(record.agent_runtime_id.as_str()),
6470 )
6471 .await
6472 {
6473 Some(json!({
6474 "kind": "stale_member_cleanup_failed_after_identity_respawn",
6475 "identity": parsed_identity.as_str(),
6476 "agent_runtime_id": record.agent_runtime_id.as_str(),
6477 "message": err,
6478 }))
6479 } else {
6480 None
6481 };
6482 if let Some(store) = &console_events {
6483 store
6484 .record_lifecycle(
6485 parsed_identity.as_str(),
6486 "identity_respawned",
6487 json!({
6488 "generation": record.generation.get(),
6489 "checkpoint_version": record.checkpoint_version.get(),
6490 "live_respawn_warning": live_respawn_warning.clone(),
6491 "cleanup_warning": cleanup_warning.clone(),
6492 }),
6493 )
6494 .await;
6495 }
6496 return response_value(
6497 response_id,
6498 Some(json!({
6499 "identity": record.identity.as_str(),
6500 "agent_runtime_id": record.agent_runtime_id.as_str(),
6501 "session_id": record.session_id.to_string(),
6502 "generation": record.generation.get(),
6503 "checkpoint_version": record.checkpoint_version.get(),
6504 "live_respawn_warning": live_respawn_warning,
6505 "cleanup_warning": cleanup_warning,
6506 })),
6507 None,
6508 );
6509 }
6510 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6511 if live_alias.is_none() {
6512 return invalid_params(
6513 response_id,
6514 format!("identity not found: {identity}"),
6515 );
6516 }
6517 }
6518 Err(err) => {
6519 return console_identity_error_response(response_id, "respawn", err);
6520 }
6521 }
6522 }
6523 let live_alias = match lookup_member_alias_with_session(
6524 &handle,
6525 visibility_policy,
6526 identity,
6527 )
6528 .await
6529 {
6530 Ok(alias) => alias,
6531 Err(err) => return response_value(response_id, None, Some(err)),
6532 };
6533 let Some(alias) = live_alias else {
6534 return invalid_params(response_id, format!("identity not found: {identity}"));
6535 };
6536 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6537 return identity_hidden_by_policy_response(response_id, identity);
6538 }
6539 if let Err(err) =
6540 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
6541 {
6542 return response_value(response_id, None, Some(err));
6543 }
6544 Box::pin(respawn_live_console_member_response(
6545 &handle,
6546 console_events.as_ref(),
6547 &alias,
6548 "identity_respawned",
6549 "respawn",
6550 response_id,
6551 ))
6552 .await
6553 }
6554 "mobkit/reset" => {
6555 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6556 return invalid_params(response_id, "identity required");
6557 };
6558 let handle = runtime.handle();
6559 let Some(identity_runtime) = &identity_runtime else {
6560 let live_alias =
6566 match lookup_member_alias_with_session(&handle, visibility_policy, identity)
6567 .await
6568 {
6569 Ok(alias) => alias,
6570 Err(err) => return response_value(response_id, None, Some(err)),
6571 };
6572 let Some(alias) = live_alias else {
6573 return invalid_params(response_id, format!("identity not found: {identity}"));
6574 };
6575 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6576 return identity_hidden_by_policy_response(response_id, identity);
6577 }
6578 if let Err(err) =
6579 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias)
6580 .await
6581 {
6582 return response_value(response_id, None, Some(err));
6583 }
6584 return Box::pin(respawn_live_console_member_response(
6585 &handle,
6586 console_events.as_ref(),
6587 &alias,
6588 "identity_reset",
6589 "reset",
6590 response_id,
6591 ))
6592 .await;
6593 };
6594 let (parsed_identity, _requested_exact_identity, live_alias) =
6595 match resolve_console_identity_control_target(
6596 &handle,
6597 Some(identity_runtime),
6598 visibility_policy,
6599 identity,
6600 )
6601 .await
6602 {
6603 Ok(Some(target)) => target,
6604 Ok(None) => {
6605 return invalid_params(
6606 response_id,
6607 format!("identity not found: {identity}"),
6608 );
6609 }
6610 Err(err) => return response_value(response_id, None, Some(err)),
6611 };
6612 match identity_runtime.status(&parsed_identity).await {
6613 Ok(status) => {
6614 if !identity_status_visible_to_console(visibility_policy, &status) {
6615 return identity_hidden_by_policy_response(response_id, identity);
6616 }
6617 if let Some(error) = stale_live_alias_json_rpc_error(
6618 "reset",
6619 identity_runtime,
6620 &parsed_identity,
6621 live_alias.as_ref(),
6622 )
6623 .await
6624 {
6625 return response_value(response_id, None, Some(error));
6626 }
6627 if !identity_runtime.has_session_bridge() {
6628 return response_value(
6629 response_id,
6630 None,
6631 Some(reset_requires_session_bridge_json_rpc_error()),
6632 );
6633 }
6634 }
6635 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6636 if let Some(alias) = live_alias.as_ref() {
6637 if !runtime_alias_visible_to_console(&handle, visibility_policy, alias) {
6638 return identity_hidden_by_policy_response(response_id, identity);
6639 }
6640 return Box::pin(respawn_live_console_member_response(
6641 &handle,
6642 console_events.as_ref(),
6643 alias,
6644 "identity_reset",
6645 "reset",
6646 response_id,
6647 ))
6648 .await;
6649 }
6650 return invalid_params(response_id, format!("identity not found: {identity}"));
6651 }
6652 Err(err) => return console_identity_error_response(response_id, "reset", err),
6653 }
6654 match identity_runtime.reset(&parsed_identity).await {
6655 Ok(record) => {
6656 let cleanup_warning = Some(json!({
6657 "kind": "stale_member_cleanup_skipped_after_identity_reset",
6658 "identity": parsed_identity.as_str(),
6659 "agent_runtime_id": record.agent_runtime_id.as_str(),
6660 "message": "reset published the new generation without retiring stale live mob members; identity control calls reject stale runtime ids",
6661 }));
6662 if let Some(store) = &console_events {
6663 store
6664 .record_lifecycle(
6665 parsed_identity.as_str(),
6666 "identity_reset",
6667 json!({
6668 "generation": record.generation.get(),
6669 "checkpoint_version": record.checkpoint_version.get(),
6670 "cleanup_warning": cleanup_warning.clone(),
6671 }),
6672 )
6673 .await;
6674 }
6675 response_value(
6676 response_id,
6677 Some(json!({
6678 "identity": record.identity.as_str(),
6679 "agent_runtime_id": record.agent_runtime_id.as_str(),
6680 "session_id": record.session_id.to_string(),
6681 "generation": record.generation.get(),
6682 "checkpoint_version": record.checkpoint_version.get(),
6683 "cleanup_warning": cleanup_warning,
6684 })),
6685 None,
6686 )
6687 }
6688 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6689 invalid_params(response_id, format!("identity not found: {identity}"))
6690 }
6691 Err(err) => console_identity_error_response(response_id, "reset", err),
6692 }
6693 }
6694 "mobkit/delete_identity" => {
6695 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6696 return invalid_params(response_id, "identity required");
6697 };
6698 let handle = runtime.handle();
6699 let Some(identity_runtime) = &identity_runtime else {
6700 return invalid_params(
6701 response_id,
6702 "identity-first runtime required for delete_identity",
6703 );
6704 };
6705 let (parsed_identity, _requested_exact_identity, live_alias) =
6706 match resolve_console_identity_control_target(
6707 &handle,
6708 Some(identity_runtime),
6709 visibility_policy,
6710 identity,
6711 )
6712 .await
6713 {
6714 Ok(Some(target)) => target,
6715 Ok(None) => {
6716 return invalid_params(
6717 response_id,
6718 format!("identity not found: {identity}"),
6719 );
6720 }
6721 Err(err) => return response_value(response_id, None, Some(err)),
6722 };
6723 let registered_status = match identity_runtime.status(&parsed_identity).await {
6724 Ok(status) => status,
6725 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6726 if let Some(alias) = live_alias.as_ref() {
6727 if !runtime_alias_visible_to_console(&handle, visibility_policy, alias) {
6728 return identity_hidden_by_policy_response(response_id, identity);
6729 }
6730 return response_value(
6731 response_id,
6732 None,
6733 Some(JsonRpcError {
6734 code: -32602,
6735 message: format!(
6736 "delete_identity requires durable identity: {} is live-only",
6737 parsed_identity.as_str()
6738 ),
6739 data: Some(json!({
6740 "kind": "live_only_identity_delete_unsupported",
6741 "identity": parsed_identity.as_str(),
6742 })),
6743 }),
6744 );
6745 }
6746 return invalid_params(response_id, format!("identity not found: {identity}"));
6747 }
6748 Err(err) => {
6749 return console_identity_error_response(response_id, "delete_identity", err);
6750 }
6751 };
6752 if !identity_status_visible_to_console(visibility_policy, ®istered_status) {
6753 return identity_hidden_by_policy_response(response_id, identity);
6754 }
6755 if let Some(error) = stale_live_alias_json_rpc_error(
6756 "delete_identity",
6757 identity_runtime,
6758 &parsed_identity,
6759 live_alias.as_ref(),
6760 )
6761 .await
6762 {
6763 return response_value(response_id, None, Some(error));
6764 }
6765 match identity_runtime.delete_identity(&parsed_identity).await {
6766 Ok(()) => {
6767 let keep_runtime_member_id = registered_status
6768 .agent_runtime_id
6769 .as_ref()
6770 .filter(|_| identity_runtime.has_session_bridge())
6771 .map(crate::identity_first::AgentRuntimeId::as_str);
6772 let cleanup_warning = if let Err(err) =
6773 retire_stale_console_members_for_identity(
6774 &handle,
6775 visibility_policy,
6776 parsed_identity.as_str(),
6777 keep_runtime_member_id,
6778 )
6779 .await
6780 {
6781 Some(json!({
6782 "kind": "stale_member_cleanup_failed_after_identity_delete",
6783 "identity": parsed_identity.as_str(),
6784 "message": err,
6785 }))
6786 } else {
6787 None
6788 };
6789 if let Some(store) = &console_events {
6790 store
6791 .record_lifecycle(
6792 parsed_identity.as_str(),
6793 "identity_deleted",
6794 json!({
6795 "cleanup_warning": cleanup_warning.clone(),
6796 }),
6797 )
6798 .await;
6799 }
6800 response_value(
6801 response_id,
6802 Some(json!({
6803 "identity": parsed_identity.as_str(),
6804 "cleanup_warning": cleanup_warning,
6805 })),
6806 None,
6807 )
6808 }
6809 Err(err) => console_identity_error_response(response_id, "delete_identity", err),
6810 }
6811 }
6812 "mobkit/reset_all" => {
6813 match Box::pin(reset_all_live_console_agents(
6814 runtime,
6815 console_events.as_ref(),
6816 console_aggregator.as_ref(),
6817 identity_runtime.as_ref(),
6818 visibility_policy,
6819 ))
6820 .await
6821 {
6822 Ok(body) => {
6823 if body
6824 .get("failed")
6825 .and_then(Value::as_array)
6826 .is_some_and(|failed| !failed.is_empty())
6827 {
6828 response_value(
6829 response_id,
6830 None,
6831 Some(JsonRpcError {
6832 code: -32000,
6833 message: "reset_all failed for one or more identities".to_string(),
6834 data: Some(body),
6835 }),
6836 )
6837 } else {
6838 response_value(response_id, Some(body), None)
6839 }
6840 }
6841 Err(err) => internal_error(response_id, format!("reset_all failed: {err}")),
6842 }
6843 }
6844 "mobkit/routing/routes/list" => {
6845 let Some(module_runtime) = &module_runtime else {
6846 return response_value(
6847 response_id,
6848 None,
6849 Some(JsonRpcError {
6850 code: -32601,
6851 message: "Method not found".to_string(),
6852 data: None,
6853 }),
6854 );
6855 };
6856 let routes = module_runtime.lock().await.list_runtime_routes();
6857 response_value(response_id, Some(json!({ "routes": routes })), None)
6858 }
6859 "mobkit/delivery/history" => {
6860 let Some(module_runtime) = &module_runtime else {
6861 return response_value(
6862 response_id,
6863 None,
6864 Some(JsonRpcError {
6865 code: -32601,
6866 message: "Method not found".to_string(),
6867 data: None,
6868 }),
6869 );
6870 };
6871 let limit = request
6872 .params
6873 .get("limit")
6874 .and_then(Value::as_u64)
6875 .unwrap_or(50) as usize;
6876 let history = module_runtime
6877 .lock()
6878 .await
6879 .delivery_history(DeliveryHistoryRequest {
6880 recipient: None,
6881 sink: None,
6882 limit,
6883 });
6884 response_value(
6885 response_id,
6886 Some(serde_json::to_value(history).unwrap_or(Value::Null)),
6887 None,
6888 )
6889 }
6890 "mobkit/gating/pending" => {
6891 let Some(module_runtime) = &module_runtime else {
6892 return response_value(
6893 response_id,
6894 None,
6895 Some(JsonRpcError {
6896 code: -32601,
6897 message: "Method not found".to_string(),
6898 data: None,
6899 }),
6900 );
6901 };
6902 let pending = module_runtime.lock().await.list_gating_pending();
6903 response_value(response_id, Some(json!({ "pending": pending })), None)
6904 }
6905 "mobkit/gating/audit" => {
6906 let Some(module_runtime) = &module_runtime else {
6907 return response_value(
6908 response_id,
6909 None,
6910 Some(JsonRpcError {
6911 code: -32601,
6912 message: "Method not found".to_string(),
6913 data: None,
6914 }),
6915 );
6916 };
6917 let limit = request
6918 .params
6919 .get("limit")
6920 .and_then(Value::as_u64)
6921 .unwrap_or(50) as usize;
6922 let entries = module_runtime.lock().await.gating_audit_entries(limit);
6923 response_value(response_id, Some(json!({ "entries": entries })), None)
6924 }
6925 "mobkit/gating/decide" => {
6926 let Some(module_runtime) = &module_runtime else {
6927 return response_value(
6928 response_id,
6929 None,
6930 Some(JsonRpcError {
6931 code: -32601,
6932 message: "Method not found".to_string(),
6933 data: None,
6934 }),
6935 );
6936 };
6937 let Some(pending_id) = request.params.get("pending_id").and_then(Value::as_str) else {
6938 return invalid_params(response_id, "pending_id required");
6939 };
6940 let approver_id =
6941 match resolve_gating_approver_id(&request.params, authenticated_principal) {
6942 Ok(approver_id) => approver_id,
6943 Err(message) => return invalid_params(response_id, message),
6944 };
6945 let Some(raw_decision) = request.params.get("decision").and_then(Value::as_str) else {
6946 return invalid_params(response_id, "decision required");
6947 };
6948 let decision = match raw_decision {
6949 "approve" => GatingDecision::Approve,
6950 "reject" | "deny" => GatingDecision::Reject,
6951 "escalate" => GatingDecision::Escalate,
6952 _ => {
6953 return invalid_params(
6954 response_id,
6955 format!("unsupported decision: {raw_decision}"),
6956 );
6957 }
6958 };
6959 let reason = request
6960 .params
6961 .get("reason")
6962 .and_then(Value::as_str)
6963 .map(ToString::to_string);
6964 match module_runtime
6965 .lock()
6966 .await
6967 .decide_gating_action(GatingDecideRequest {
6968 pending_id: pending_id.to_string(),
6969 approver_id,
6970 decision,
6971 reason,
6972 }) {
6973 Ok(result) => response_value(
6974 response_id,
6975 Some(serde_json::to_value(result).unwrap_or(Value::Null)),
6976 None,
6977 ),
6978 Err(err) => gating_decision_failed_error(response_id, err),
6979 }
6980 }
6981 "mobkit/ensure_member" => {
6982 let Some(role) = request.params.get("role").and_then(Value::as_str) else {
6983 return invalid_params(response_id, "role required");
6984 };
6985 let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
6986 else {
6987 return invalid_params(response_id, "agent_identity required");
6988 };
6989 let labels = match request.params.get("labels") {
6990 None | Some(Value::Null) => std::collections::BTreeMap::new(),
6991 Some(value) => match serde_json::from_value(value.clone()) {
6992 Ok(map) => map,
6993 Err(err) => {
6994 return invalid_params(response_id, format!("invalid labels: {err}"));
6995 }
6996 },
6997 };
6998 let context = request.params.get("context").cloned();
6999 let resume_session_id = match request.params.get("resume_session_id") {
7000 None => None,
7001 Some(Value::Null) => None,
7002 Some(v) => match v.as_str() {
7003 Some(s) => match meerkat_core::types::SessionId::parse(s) {
7004 Ok(sid) => Some(sid),
7005 Err(_) => {
7006 return invalid_params(
7007 response_id,
7008 format!("invalid resume_session_id: {s:?}"),
7009 );
7010 }
7011 },
7012 None => {
7013 return invalid_params(
7014 response_id,
7015 "resume_session_id must be a string".to_string(),
7016 );
7017 }
7018 },
7019 };
7020 let additional_instructions = match request.params.get("additional_instructions") {
7021 None | Some(Value::Null) => None,
7022 Some(Value::Array(arr)) => {
7023 let mut strs = Vec::with_capacity(arr.len());
7024 for (i, entry) in arr.iter().enumerate() {
7025 match entry.as_str() {
7026 Some(s) => strs.push(s.to_string()),
7027 None => {
7028 return invalid_params(
7029 response_id,
7030 format!("additional_instructions[{i}] must be a string"),
7031 );
7032 }
7033 }
7034 }
7035 if strs.is_empty() { None } else { Some(strs) }
7036 }
7037 Some(_) => {
7038 return invalid_params(
7039 response_id,
7040 "additional_instructions must be an array of strings",
7041 );
7042 }
7043 };
7044 let runtime_mode = match parse_optional_runtime_mode(&request.params) {
7045 Ok(value) => value,
7046 Err(message) => return invalid_params(response_id, message),
7047 };
7048 let backend = match parse_optional_backend(&request.params) {
7049 Ok(value) => value,
7050 Err(message) => return invalid_params(response_id, message),
7051 };
7052 let binding = match parse_optional_runtime_binding(&request.params) {
7053 Ok(value) => value,
7054 Err(message) => return invalid_params(response_id, message),
7055 };
7056 let worker_plane = matches!(
7065 request.params.get("plane").and_then(Value::as_str),
7066 Some("worker")
7067 );
7068 if !worker_plane
7069 && let (Some(identity_runtime_ref), Some(roster)) =
7070 (identity_runtime.as_ref(), identity_roster.as_ref())
7071 {
7072 let identity = match crate::identity_first::AgentIdentity::parse(agent_identity) {
7073 Ok(identity) => identity,
7074 Err(err) => {
7075 return invalid_params(
7076 response_id,
7077 format!("invalid agent_identity: {err}"),
7078 );
7079 }
7080 };
7081 if resume_session_id.is_some() {
7082 return invalid_params(
7083 response_id,
7084 "resume_session_id is not supported on an identity-first \
7085 gateway: continuity records own session resumption",
7086 );
7087 }
7088 if binding.is_some() {
7089 return invalid_params(
7090 response_id,
7091 "external runtime bindings are not supported on an \
7092 identity-first gateway yet",
7093 );
7094 }
7095 let spec = crate::identity_first::DurableAgentSpec {
7096 identity: identity.clone(),
7097 profile: ProfileName::from(role),
7098 addressability: crate::identity_first::AgentAddressability::Addressable,
7099 display_name: None,
7100 labels,
7101 context,
7102 additional_instructions: additional_instructions.unwrap_or_default(),
7103 initial_message: None,
7104 runtime_mode_override: runtime_mode,
7105 backend,
7106 binding: None,
7107 };
7108 roster.upsert(spec);
7109 return match crate::identity_first::restore_flow(
7110 identity_runtime_ref,
7111 &roster.snapshot(),
7112 None,
7113 None,
7114 )
7115 .await
7116 {
7117 Ok(result) => {
7118 let _ = reconcile_console_topology(runtime, topology).await;
7122 let outcome = match result.outcomes.get(&identity) {
7123 Some(crate::identity_first::RestoreOutcome::Created { .. }) => {
7124 "created"
7125 }
7126 Some(crate::identity_first::RestoreOutcome::Resumed { .. }) => {
7127 "resumed"
7128 }
7129 Some(crate::identity_first::RestoreOutcome::Dormant { .. }) => {
7130 "dormant"
7131 }
7132 Some(crate::identity_first::RestoreOutcome::Broken(_)) => "broken",
7133 None => "unchanged",
7134 };
7135 let state = identity_runtime_ref
7136 .status(&identity)
7137 .await
7138 .map(|status| status.state.wire_str().to_string())
7139 .unwrap_or_else(|_| "unknown".to_string());
7140 response_value(
7141 response_id,
7142 Some(serde_json::json!({
7143 "agent_identity": identity.as_str(),
7144 "role": role,
7145 "identity_first": true,
7146 "outcome": outcome,
7147 "state": state,
7148 })),
7149 None,
7150 )
7151 }
7152 Err(err) => {
7153 internal_error(response_id, format!("ensure_member (identity): {err}"))
7154 }
7155 };
7156 }
7157 let mut spec = SpawnMemberSpec::new(
7158 ProfileName::from(role),
7159 crate::member_comms_id::mob_member_id(agent_identity),
7160 );
7161 if let Some(runtime_mode) = runtime_mode {
7162 spec = spec.with_runtime_mode(runtime_mode);
7163 }
7164 if let Some(backend) = backend {
7165 spec = spec.with_backend(backend);
7166 }
7167 if let Some(binding) = binding {
7168 spec.binding = Some(binding);
7169 }
7170 if !labels.is_empty() {
7171 spec = spec.with_labels(labels);
7172 }
7173 if let Some(ctx) = context {
7174 spec = spec.with_context(ctx);
7175 }
7176 if let Some(sid) = resume_session_id {
7177 spec = spec.with_resume_bridge_session_id(sid);
7178 }
7179 if let Some(instructions) = additional_instructions {
7180 spec = spec.with_additional_instructions(instructions);
7181 }
7182 let handle = runtime.handle();
7183 let mid = spec.identity.clone();
7184 match handle.ensure_member(spec).await {
7185 Ok(_outcome) => {
7186 let _ = reconcile_console_topology(runtime, topology).await;
7187 let body = match lookup_member_with_session(&handle, &mid).await {
7188 Some((entry, _sid)) => member_entry_to_json(&entry),
7189 None => Value::Null,
7190 };
7191 response_value(response_id, Some(body), None)
7192 }
7193 Err(err) => internal_error(response_id, format!("ensure_member failed: {err}")),
7194 }
7195 }
7196 "mobkit/retire_member" => {
7197 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
7198 return invalid_params(response_id, "member_id required");
7199 };
7200 if let Some(error) = stale_runtime_alias_json_rpc_error(
7201 "retire_member",
7202 identity_runtime.as_ref(),
7203 member_id,
7204 )
7205 .await
7206 {
7207 return response_value(response_id, None, Some(error));
7208 }
7209 if let (Some(identity_runtime_ref), Some(roster)) =
7210 (identity_runtime.as_ref(), identity_roster.as_ref())
7211 {
7212 let identity = match crate::identity_first::AgentIdentity::parse(member_id) {
7213 Ok(identity) => identity,
7214 Err(err) => {
7215 return invalid_params(response_id, format!("invalid member_id: {err}"));
7216 }
7217 };
7218 return match identity_runtime_ref.retire(&identity).await {
7219 Ok(_token) => {
7220 roster.remove(&identity);
7223 response_value(
7224 response_id,
7225 Some(serde_json::json!({
7226 "accepted": true,
7227 "identity_first": true,
7228 })),
7229 None,
7230 )
7231 }
7232 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
7233 response_value(
7234 response_id,
7235 None,
7236 Some(JsonRpcError {
7237 code: -32001,
7238 message: format!("unknown identity: {member_id}"),
7239 data: None,
7240 }),
7241 )
7242 }
7243 Err(err) => {
7244 internal_error(response_id, format!("retire_member (identity): {err}"))
7245 }
7246 };
7247 }
7248 if let Some(identity_runtime_ref) = identity_runtime.as_ref()
7252 && let Some(durable) = identity_runtime_ref
7253 .owned_identity_for_member_alias(member_id)
7254 .await
7255 {
7256 return match identity_runtime_ref.retire(&durable).await {
7257 Ok(_token) => response_value(
7258 response_id,
7259 Some(serde_json::json!({
7260 "accepted": true,
7261 "identity_first": true,
7262 })),
7263 None,
7264 ),
7265 Err(err) => {
7266 internal_error(response_id, format!("retire_member (identity): {err}"))
7267 }
7268 };
7269 }
7270 if let Some(aggregator) = &console_aggregator {
7271 return match Box::pin(aggregator.retire_identity(member_id)).await {
7272 Ok(true) => response_value(
7273 response_id,
7274 Some(serde_json::json!({ "accepted": true })),
7275 None,
7276 ),
7277 Ok(false) => response_value(
7278 response_id,
7279 None,
7280 Some(JsonRpcError {
7281 code: -32001,
7282 message: format!("unknown identity: {member_id}"),
7283 data: None,
7284 }),
7285 ),
7286 Err(err) => internal_error(response_id, format!("retire_member failed: {err}")),
7287 };
7288 }
7289 match retire_console_member(
7292 &runtime.handle(),
7293 &crate::member_comms_id::mob_member_id(member_id),
7294 )
7295 .await
7296 {
7297 Ok(()) => response_value(
7298 response_id,
7299 Some(serde_json::json!({ "accepted": true })),
7300 None,
7301 ),
7302 Err(err) => internal_error(response_id, format!("retire_member failed: {err}")),
7303 }
7304 }
7305 "mobkit/respawn_member" => {
7306 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
7307 return invalid_params(response_id, "member_id required");
7308 };
7309 if let Some(error) = stale_runtime_alias_json_rpc_error(
7310 "respawn_member",
7311 identity_runtime.as_ref(),
7312 member_id,
7313 )
7314 .await
7315 {
7316 return response_value(response_id, None, Some(error));
7317 }
7318 if identity_roster.is_some()
7319 && let Some(identity_runtime_ref) = identity_runtime.as_ref()
7320 {
7321 let identity = match crate::identity_first::AgentIdentity::parse(member_id) {
7322 Ok(identity) => identity,
7323 Err(err) => {
7324 return invalid_params(response_id, format!("invalid member_id: {err}"));
7325 }
7326 };
7327 return match identity_runtime_ref.reset(&identity).await {
7331 Ok(record) => response_value(
7332 response_id,
7333 Some(serde_json::json!({
7334 "accepted": true,
7335 "identity_first": true,
7336 "session_id": record.session_id.to_string(),
7337 "generation": record.generation.get(),
7338 })),
7339 None,
7340 ),
7341 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
7342 response_value(
7343 response_id,
7344 None,
7345 Some(JsonRpcError {
7346 code: -32001,
7347 message: format!("unknown identity: {member_id}"),
7348 data: None,
7349 }),
7350 )
7351 }
7352 Err(err) => {
7353 internal_error(response_id, format!("respawn_member (identity): {err}"))
7354 }
7355 };
7356 }
7357 if let Some(identity_runtime_ref) = identity_runtime.as_ref()
7358 && let Some(durable) = identity_runtime_ref
7359 .owned_identity_for_member_alias(member_id)
7360 .await
7361 {
7362 return match identity_runtime_ref.reset(&durable).await {
7363 Ok(record) => response_value(
7364 response_id,
7365 Some(serde_json::json!({
7366 "accepted": true,
7367 "identity_first": true,
7368 "session_id": record.session_id.to_string(),
7369 "generation": record.generation.get(),
7370 })),
7371 None,
7372 ),
7373 Err(err) => {
7374 internal_error(response_id, format!("respawn_member (identity): {err}"))
7375 }
7376 };
7377 }
7378 match Box::pin(respawn_console_member(
7381 &runtime.handle(),
7382 &crate::member_comms_id::mob_member_id(member_id),
7383 ))
7384 .await
7385 {
7386 Ok(topology_restore_warning) => {
7387 let mut body = serde_json::json!({ "accepted": true });
7388 if let Some(warning) = topology_restore_warning {
7389 body["topology_restore_warning"] = warning;
7390 }
7391 response_value(response_id, Some(body), None)
7392 }
7393 Err(err) => internal_error(response_id, format!("respawn_member failed: {err}")),
7394 }
7395 }
7396 "mobkit/reconcile_edges" => {
7397 let report = reconcile_console_topology(runtime, topology).await;
7402 response_value(
7403 response_id,
7404 Some(serde_json::to_value(&report).unwrap_or(serde_json::Value::Null)),
7405 None,
7406 )
7407 }
7408 "mobkit/mob_events/query" | "mobkit/mob_events/subscribe" => {
7409 let query: EventQuery = if request.params.is_null() {
7410 EventQuery::default()
7411 } else {
7412 match serde_json::from_value(request.params.clone()) {
7413 Ok(q) => q,
7414 Err(err) => {
7415 return invalid_params(response_id, format!("invalid query params: {err}"));
7416 }
7417 }
7418 };
7419 let Some(store) = mob_events.as_ref() else {
7420 return response_value(
7421 response_id,
7422 Some(serde_json::json!({
7423 "events": [],
7424 "next_after_seq": Value::Null,
7425 })),
7426 None,
7427 );
7428 };
7429 let events_view = runtime.handle().events();
7430 let latest_at_handshake = events_view.latest_cursor().await.unwrap_or(0);
7434 let result = crate::unified_runtime::mob_events::query_ledger_with_filter(
7435 &events_view,
7436 store,
7437 &query,
7438 )
7439 .await;
7440 match result {
7441 Ok(mut events) => {
7442 if let Some(view) = access_view.filter(|view| view.enforced()) {
7448 events.retain(|event| {
7449 event
7450 .agent_identity
7451 .as_deref()
7452 .is_none_or(|identity| view.can_view_agent(identity))
7453 });
7454 }
7455 let last_cursor = events.last().map(|event| event.cursor);
7456 let body = if request.method == "mobkit/mob_events/subscribe" {
7457 let subscribe_url = crate::unified_runtime::mob_events::build_subscribe_url(
7458 &query,
7459 last_cursor,
7460 latest_at_handshake,
7461 );
7462 serde_json::json!({
7463 "stream": "mob_events",
7464 "events": events,
7465 "next_after_seq": last_cursor,
7466 "subscribe_url": subscribe_url,
7467 "keep_alive": {
7468 "interval_ms": 15_000_u64,
7469 "event": "keep_alive",
7470 },
7471 })
7472 } else {
7473 serde_json::json!({
7474 "events": events,
7475 "next_after_seq": last_cursor,
7476 })
7477 };
7478 response_value(response_id, Some(body), None)
7479 }
7480 Err(crate::unified_runtime::mob_events::MobEventsQueryError::Stale {
7481 after_cursor,
7482 latest_cursor,
7483 }) => stale_event_cursor_response(response_id, after_cursor, latest_cursor),
7484 Err(err) => internal_error(response_id, format!("mob_events query failed: {err}")),
7485 }
7486 }
7487 "mobkit/member_status" => {
7489 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
7490 return invalid_params(response_id, "member_id required");
7491 };
7492 if let Some(error) = stale_runtime_alias_json_rpc_error(
7493 "member_status",
7494 identity_runtime.as_ref(),
7495 member_id,
7496 )
7497 .await
7498 {
7499 return response_value(response_id, None, Some(error));
7500 }
7501 match runtime
7502 .handle()
7503 .member_status(&crate::member_comms_id::mob_member_id(member_id))
7504 .await
7505 {
7506 Ok(snapshot) => response_value(
7507 response_id,
7508 Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
7509 None,
7510 ),
7511 Err(err) => internal_error(response_id, format!("member_status failed: {err}")),
7512 }
7513 }
7514 "mobkit/identity/resolved_tools" => {
7515 let Some(identity) = request
7516 .params
7517 .get("identity")
7518 .or_else(|| request.params.get("member_id"))
7519 .and_then(Value::as_str)
7520 else {
7521 return invalid_params(response_id, "identity required");
7522 };
7523 if let Some(error) = stale_runtime_alias_json_rpc_error(
7524 "identity_resolved_tools",
7525 identity_runtime.as_ref(),
7526 identity,
7527 )
7528 .await
7529 {
7530 return response_value(response_id, None, Some(error));
7531 }
7532 if let Some(identity_runtime) = identity_runtime.as_ref()
7533 && let Ok(parsed) = crate::identity_first::AgentIdentity::parse(identity)
7534 && let Ok(status) = identity_runtime.status(&parsed).await
7535 && let Some(session_id) = status.session_id
7536 {
7537 match resolved_tools_for_session(runtime.session_service(), identity, session_id)
7538 .await
7539 {
7540 Ok(snapshot) => {
7541 return response_value(
7542 response_id,
7543 Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
7544 None,
7545 );
7546 }
7547 Err(err) => {
7548 return internal_error(
7549 response_id,
7550 format!("resolved_tools failed: {err}"),
7551 );
7552 }
7553 }
7554 }
7555 match resolved_tools_for_member(&runtime.handle(), runtime.session_service(), identity)
7556 .await
7557 {
7558 Ok(snapshot) => response_value(
7559 response_id,
7560 Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
7561 None,
7562 ),
7563 Err(err) => internal_error(response_id, format!("resolved_tools failed: {err}")),
7564 }
7565 }
7566 "mobkit/force_cancel_member" => {
7567 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
7568 return invalid_params(response_id, "member_id required");
7569 };
7570 if let Some(error) = stale_runtime_alias_json_rpc_error(
7571 "force_cancel_member",
7572 identity_runtime.as_ref(),
7573 member_id,
7574 )
7575 .await
7576 {
7577 return response_value(response_id, None, Some(error));
7578 }
7579 match runtime
7580 .handle()
7581 .force_cancel_member(crate::member_comms_id::mob_member_id(member_id))
7582 .await
7583 {
7584 Ok(()) => response_value(
7585 response_id,
7586 Some(serde_json::json!({ "accepted": true })),
7587 None,
7588 ),
7589 Err(err) => {
7590 internal_error(response_id, format!("force_cancel_member failed: {err}"))
7591 }
7592 }
7593 }
7594 "mobkit/wait_ready" => {
7595 let timeout = Some(
7599 request
7600 .params
7601 .get("timeout_ms")
7602 .and_then(Value::as_u64)
7603 .map(std::time::Duration::from_millis)
7604 .unwrap_or(crate::unified_runtime::mob_ops::DEFAULT_WAIT_READY_TIMEOUT),
7605 );
7606 match runtime.handle().wait_for_ready(timeout).await {
7607 Ok(ready) => {
7608 let entries: Vec<Value> = ready
7609 .into_iter()
7610 .filter_map(|(identity, snapshot)| {
7614 let identity = identity.to_string();
7615 let visible = access_view
7616 .filter(|view| view.enforced())
7617 .is_none_or(|view| view.can_view_agent(&identity));
7618 visible.then(|| {
7619 serde_json::json!({
7620 "agent_identity": identity,
7621 "snapshot": serde_json::to_value(&snapshot)
7622 .unwrap_or(Value::Null),
7623 })
7624 })
7625 })
7626 .collect();
7627 response_value(
7628 response_id,
7629 Some(serde_json::json!({
7630 "ready": entries,
7631 "timeout": false,
7632 })),
7633 None,
7634 )
7635 }
7636 Err(err) => {
7637 if crate::unified_runtime::mob_ops::is_ready_wait_timeout(&err) {
7638 response_value(
7639 response_id,
7640 Some(serde_json::json!({
7641 "ready": Vec::<Value>::new(),
7642 "timeout": true,
7643 })),
7644 None,
7645 )
7646 } else {
7647 internal_error(response_id, format!("wait_for_ready failed: {err}"))
7648 }
7649 }
7650 }
7651 }
7652 "mobkit/collect_completed" => {
7653 let completed = runtime.handle().collect_completed().await;
7654 let entries: Vec<Value> = completed
7655 .into_iter()
7656 .filter_map(|(mid, snapshot)| {
7659 let mid = mid.to_string();
7660 let visible = access_view
7661 .filter(|view| view.enforced())
7662 .is_none_or(|view| view.can_view_agent(&mid));
7663 visible.then(|| {
7664 serde_json::json!({
7665 "member_id": mid,
7666 "snapshot": serde_json::to_value(&snapshot).unwrap_or(Value::Null),
7667 })
7668 })
7669 })
7670 .collect();
7671 response_value(
7672 response_id,
7673 Some(serde_json::json!({ "completed": entries })),
7674 None,
7675 )
7676 }
7677 "mobkit/cancel_flow" => {
7678 let Some(run_id) = request.params.get("run_id").and_then(Value::as_str) else {
7679 return invalid_params(response_id, "run_id required");
7680 };
7681 let run_id: meerkat_mob::RunId = match run_id.parse() {
7682 Ok(id) => id,
7683 Err(_) => return invalid_params(response_id, "invalid run_id format"),
7684 };
7685 match runtime.handle().cancel_flow(run_id).await {
7686 Ok(()) => response_value(
7687 response_id,
7688 Some(serde_json::json!({ "accepted": true })),
7689 None,
7690 ),
7691 Err(err) => internal_error(response_id, format!("cancel_flow failed: {err}")),
7692 }
7693 }
7694 "mobkit/flow_status" => {
7695 let Some(run_id) = request.params.get("run_id").and_then(Value::as_str) else {
7696 return invalid_params(response_id, "run_id required");
7697 };
7698 let run_id: meerkat_mob::RunId = match run_id.parse() {
7699 Ok(id) => id,
7700 Err(_) => return invalid_params(response_id, "invalid run_id format"),
7701 };
7702 match runtime.handle().flow_status(run_id).await {
7703 Ok(Some(mob_run)) => response_value(
7704 response_id,
7705 Some(serde_json::to_value(&mob_run).unwrap_or(Value::Null)),
7706 None,
7707 ),
7708 Ok(None) => response_value(response_id, Some(Value::Null), None),
7709 Err(err) => internal_error(response_id, format!("flow_status failed: {err}")),
7710 }
7711 }
7712 "mobkit/list_flows" => {
7713 let flows: Vec<String> = runtime
7714 .handle()
7715 .list_flows()
7716 .into_iter()
7717 .map(|id| id.to_string())
7718 .collect();
7719 response_value(
7720 response_id,
7721 Some(serde_json::json!({ "flows": flows })),
7722 None,
7723 )
7724 }
7725 "mobkit/list_runs" => {
7726 let flow_id = request
7727 .params
7728 .get("flow_id")
7729 .and_then(Value::as_str)
7730 .filter(|value| !value.is_empty())
7731 .map(meerkat_mob::FlowId::from);
7732 match runtime.handle().list_runs(flow_id.as_ref()).await {
7733 Ok(runs) => response_value(
7734 response_id,
7735 Some(serde_json::json!({
7736 "runs": serde_json::to_value(&runs).unwrap_or(Value::Null),
7737 })),
7738 None,
7739 ),
7740 Err(err) => internal_error(response_id, format!("list_runs failed: {err}")),
7741 }
7742 }
7743 "mobkit/run_flow" => {
7744 let Some(flow_id_str) = request.params.get("flow_id").and_then(Value::as_str) else {
7745 return invalid_params(response_id, "flow_id required");
7746 };
7747 if flow_id_str.is_empty() {
7748 return invalid_params(response_id, "flow_id required");
7749 }
7750 let flow_id = meerkat_mob::FlowId::from(flow_id_str);
7751 let flow_params = request.params.get("params").cloned().unwrap_or(Value::Null);
7752 if let Some(identity_runtime) = &identity_runtime
7753 && let Err(err) = identity_runtime.materialize_all_required().await
7754 {
7755 return internal_error(
7756 response_id,
7757 format!("identity-first flow materialization failed: {err}"),
7758 );
7759 }
7760 match Box::pin(runtime.handle().run_flow(flow_id, flow_params)).await {
7761 Ok(run_id) => response_value(
7762 response_id,
7763 Some(serde_json::json!({ "run_id": run_id.to_string() })),
7764 None,
7765 ),
7766 Err(err) => invalid_params(response_id, format!("run_flow failed: {err}")),
7767 }
7768 }
7769 "mobkit/spawn_helper" => {
7770 let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
7771 else {
7772 return invalid_params(response_id, "agent_identity required");
7773 };
7774 let Some(task) = request.params.get("task").and_then(Value::as_str) else {
7775 return invalid_params(response_id, "task required");
7776 };
7777 let options = match parse_console_helper_options(request.params.get("options")) {
7778 Ok(opts) => opts,
7779 Err(msg) => return invalid_params(response_id, msg),
7780 };
7781 let handle = runtime.handle();
7782 match Box::pin(handle.spawn_helper(
7783 crate::member_comms_id::mob_member_id(agent_identity),
7784 task,
7785 options,
7786 ))
7787 .await
7788 {
7789 Ok(result) => {
7790 response_value(
7795 response_id,
7796 Some(serde_json::json!({
7797 "output": result.output,
7798 "tokens_used": result.tokens_used,
7799 })),
7800 None,
7801 )
7802 }
7803 Err(err) => internal_error(response_id, format!("spawn_helper failed: {err}")),
7804 }
7805 }
7806 "mobkit/fork_helper" => {
7807 let Some(source) = request
7808 .params
7809 .get("source_member_id")
7810 .and_then(Value::as_str)
7811 else {
7812 return invalid_params(response_id, "source_member_id required");
7813 };
7814 let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
7815 else {
7816 return invalid_params(response_id, "agent_identity required");
7817 };
7818 let Some(task) = request.params.get("task").and_then(Value::as_str) else {
7819 return invalid_params(response_id, "task required");
7820 };
7821 let fork_context = match request.params.get("fork_context") {
7822 Some(v) if !v.is_null() => {
7823 match serde_json::from_value::<meerkat_mob::launch::ForkContext>(v.clone()) {
7824 Ok(ctx) => ctx,
7825 Err(err) => {
7826 return invalid_params(
7827 response_id,
7828 format!("invalid fork_context: {err}"),
7829 );
7830 }
7831 }
7832 }
7833 _ => meerkat_mob::launch::ForkContext::default(),
7834 };
7835 let options = match parse_console_helper_options(request.params.get("options")) {
7836 Ok(opts) => opts,
7837 Err(msg) => return invalid_params(response_id, msg),
7838 };
7839 let handle = runtime.handle();
7840 match Box::pin(handle.fork_helper(
7841 &crate::member_comms_id::mob_member_id(source),
7842 crate::member_comms_id::mob_member_id(agent_identity),
7843 task,
7844 fork_context,
7845 options,
7846 ))
7847 .await
7848 {
7849 Ok(result) => {
7850 response_value(
7854 response_id,
7855 Some(serde_json::json!({
7856 "output": result.output,
7857 "tokens_used": result.tokens_used,
7858 })),
7859 None,
7860 )
7861 }
7862 Err(err) => internal_error(response_id, format!("fork_helper failed: {err}")),
7863 }
7864 }
7865 "mobkit/attach_existing_session" => {
7866 let Some(role) = request.params.get("role").and_then(Value::as_str) else {
7867 return invalid_params(response_id, "role required");
7868 };
7869 let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
7870 else {
7871 return invalid_params(response_id, "agent_identity required");
7872 };
7873 let Some(session_id_str) = request.params.get("session_id").and_then(Value::as_str)
7874 else {
7875 return invalid_params(response_id, "session_id required");
7876 };
7877 let bridge_session_id = match meerkat_core::types::SessionId::parse(session_id_str) {
7878 Ok(s) => s,
7879 Err(_) => return invalid_params(response_id, "invalid session_id format"),
7880 };
7881 let mid = crate::member_comms_id::mob_member_id(agent_identity);
7882 let spec = SpawnMemberSpec::new(ProfileName::from(role), mid.clone())
7883 .with_launch_mode(MemberLaunchMode::Resume { bridge_session_id });
7884 let handle = runtime.handle();
7885 match Box::pin(handle.spawn_spec(spec)).await {
7886 Ok(_) => match handle.member_status(&mid).await {
7887 Ok(snapshot) => response_value(
7888 response_id,
7889 Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
7890 None,
7891 ),
7892 Err(err) => internal_error(
7893 response_id,
7894 format!("attach_existing_session status lookup failed: {err}"),
7895 ),
7896 },
7897 Err(err) => internal_error(
7898 response_id,
7899 format!("attach_existing_session failed: {err}"),
7900 ),
7901 }
7902 }
7903 "mobkit/cross_mob/wire_local" => {
7904 handle_console_wire_local(runtime, &request.params, response_id, true).await
7905 }
7906 "mobkit/cross_mob/unwire_local" => {
7907 handle_console_wire_local(runtime, &request.params, response_id, false).await
7908 }
7909 "mobkit/peer_pubkey" => match gateway_peer_keys {
7910 Some(keys) => response_value(
7911 response_id,
7912 Some(serde_json::json!({ "pubkey_b64": keys.pubkey_b64() })),
7913 None,
7914 ),
7915 None => response_value(
7916 response_id,
7917 None,
7918 Some(JsonRpcError {
7919 code: -32004,
7920 message: "gateway has no signing keypair configured".to_string(),
7921 data: None,
7922 }),
7923 ),
7924 },
7925 "mobkit/cross_mob/peer_info" => {
7926 let member_id = request.params.get("member_id").and_then(Value::as_str);
7927 match member_id {
7928 Some(mid) if !mid.is_empty() => {
7929 let handle = runtime.handle();
7930 let mob_id = handle.mob_id().to_string();
7931 let meerkat_id = crate::member_comms_id::mob_member_id(mid);
7932 match handle.get_member(&meerkat_id).await {
7933 Err(err) => response_value(
7934 response_id,
7935 None,
7936 Some(JsonRpcError {
7937 code: -32000,
7938 message: format!("member lookup failed: {err}"),
7939 data: None,
7940 }),
7941 ),
7942 Ok(Some(entry)) => match entry.peer_id() {
7943 Some(peer_id) => {
7944 let comms_name = format!("{}/{}/{}", mob_id, entry.role, mid);
7945 let address = format!("inproc://{comms_name}");
7946 response_value(
7947 response_id,
7948 Some(serde_json::json!({
7949 "member_id": mid,
7950 "mob_id": mob_id,
7951 "comms_name": comms_name,
7952 "peer_id": peer_id,
7953 "address": address,
7954 })),
7955 None,
7956 )
7957 }
7958 None => response_value(
7959 response_id,
7960 None,
7961 Some(JsonRpcError {
7962 code: -32000,
7963 message: format!("member {mid:?} has no comms runtime"),
7964 data: None,
7965 }),
7966 ),
7967 },
7968 Ok(None) => response_value(
7969 response_id,
7970 None,
7971 Some(JsonRpcError {
7972 code: -32000,
7973 message: format!("member {mid:?} not found"),
7974 data: None,
7975 }),
7976 ),
7977 }
7978 }
7979 _ => invalid_params(response_id, "member_id required".to_string()),
7980 }
7981 }
7982 "mobkit/cross_mob/directory" => {
7983 let entries: Vec<Value> = contact_directory
7984 .map(|dir| {
7985 dir.list()
7986 .into_iter()
7987 .filter_map(|e| serde_json::to_value(e).ok())
7988 .collect()
7989 })
7990 .unwrap_or_default();
7991 response_value(
7992 response_id,
7993 Some(serde_json::json!({ "mobs": entries })),
7994 None,
7995 )
7996 }
7997 method
7998 if matches!(
7999 method,
8000 "mobkit/mob_labels/set"
8001 | "mobkit/mob_labels/get"
8002 | "mobkit/mob_labels/delete"
8003 | "mobkit/run_labels/set"
8004 | "mobkit/run_labels/get"
8005 | "mobkit/run_labels/delete",
8006 ) =>
8007 {
8008 dispatch_label_method(
8009 method,
8010 metadata_table.as_deref(),
8011 runtime.handle().mob_id().as_str(),
8012 response_id,
8013 &request.params,
8014 )
8015 .await
8016 }
8017 method if crate::rpc::workgraph_methods::is_workgraph_method(method) => {
8018 let admission = runtime.workgraph_admission();
8027 match crate::rpc::workgraph_methods::handle_workgraph_method(
8028 workgraph,
8029 &admission,
8030 crate::rpc::workgraph_methods::WorkgraphSurface::Console {
8031 authenticated_principal,
8032 },
8033 method,
8034 &request.params,
8035 )
8036 .await
8037 {
8038 Ok(result) => response_value(response_id, Some(result), None),
8039 Err(error) => response_value(response_id, None, Some(error)),
8040 }
8041 }
8042 _ => response_value(
8043 response_id,
8044 None,
8045 Some(JsonRpcError {
8046 code: -32601,
8047 message: "Method not found".to_string(),
8048 data: None,
8049 }),
8050 ),
8051 }
8052}
8053
8054async fn reconcile_console_topology(
8055 runtime: &MobRuntime,
8056 topology: Option<&crate::topology_control::TopologyRuntimeHandle>,
8057) -> crate::unified_runtime::UnifiedRuntimeReconcileEdgesReport {
8058 match topology {
8059 Some(topology) => topology.reconcile().await,
8060 None => crate::unified_runtime::edge_reconcile::reconcile_definition_edges(runtime).await,
8061 }
8062}
8063
8064async fn dispatch_label_method(
8071 method: &str,
8072 metadata_table: Option<&RuntimeMetadataTable>,
8073 mob_id: &str,
8074 response_id: Value,
8075 params: &Value,
8076) -> Value {
8077 let Some(table) = metadata_table else {
8078 return invalid_params(
8079 response_id,
8080 "metadata table not configured for this runtime",
8081 );
8082 };
8083
8084 let scope = match method {
8085 "mobkit/mob_labels/set" | "mobkit/mob_labels/get" | "mobkit/mob_labels/delete" => {
8086 MetadataScope::Mob(mob_id.to_string())
8087 }
8088 _ => match crate::runtime::parse_run_id_param(params) {
8089 Ok(run_id) => MetadataScope::Run(mob_id.to_string(), run_id.to_string()),
8090 Err(message) => return invalid_params(response_id, message),
8091 },
8092 };
8093
8094 let outcome = match method {
8095 "mobkit/mob_labels/set" | "mobkit/run_labels/set" => {
8096 crate::runtime::dispatch_labels_set(table, scope, params).await
8097 }
8098 "mobkit/mob_labels/get" | "mobkit/run_labels/get" => {
8099 crate::runtime::dispatch_labels_get(table, scope).await
8100 }
8101 "mobkit/mob_labels/delete" | "mobkit/run_labels/delete" => {
8102 crate::runtime::dispatch_labels_delete(table, scope).await
8103 }
8104 _ => unreachable!("dispatch_label_method called with non-label method: {method}"),
8105 };
8106
8107 match outcome {
8108 crate::runtime::LabelRpcResult::Accepted => response_value(
8109 response_id,
8110 Some(serde_json::json!({"accepted": true})),
8111 None,
8112 ),
8113 crate::runtime::LabelRpcResult::Labels(labels) => response_value(
8114 response_id,
8115 Some(serde_json::json!({"labels": labels_to_json_value(&labels)})),
8116 None,
8117 ),
8118 crate::runtime::LabelRpcResult::InvalidParams(message) => {
8119 invalid_params(response_id, message)
8120 }
8121 }
8122}
8123
8124async fn handle_console_wire_local(
8133 runtime: &MobRuntime,
8134 params: &Value,
8135 response_id: Value,
8136 wire: bool,
8137) -> Value {
8138 let local = params.get("local_member_id").and_then(Value::as_str);
8139 let comms_name = params.get("remote_comms_name").and_then(Value::as_str);
8140 let peer_id = params.get("remote_peer_id").and_then(Value::as_str);
8141 let addr = params.get("remote_address").and_then(Value::as_str);
8142
8143 let remote_pubkey = match params.get("remote_pubkey_b64") {
8144 None => None,
8145 Some(v) if v.is_null() => None,
8146 Some(v) => match v.as_str() {
8147 Some(s) if !s.is_empty() => match crate::auth::peer_keys::decode_pubkey_b64(s) {
8148 Ok(bytes) => Some(bytes),
8149 Err(err) => {
8150 return invalid_params(response_id, format!("remote_pubkey_b64: {err}"));
8151 }
8152 },
8153 _ => None,
8154 },
8155 };
8156
8157 let (local_id, cname, pid, address) = match (local, comms_name, peer_id, addr) {
8158 (Some(l), Some(c), Some(p), Some(a))
8159 if !l.is_empty() && !c.is_empty() && !p.is_empty() && !a.is_empty() =>
8160 {
8161 (l, c, p, a)
8162 }
8163 _ => {
8164 return invalid_params(
8165 response_id,
8166 "local_member_id, remote_comms_name, remote_peer_id, and remote_address required",
8167 );
8168 }
8169 };
8170
8171 let is_inproc = address.starts_with("inproc://");
8172 let spec_result = match (is_inproc, remote_pubkey) {
8173 (true, None) => TrustedPeerDescriptor::test_only_unsigned(cname, pid, address),
8174 (true, Some(bytes)) => {
8175 TrustedPeerDescriptor::unsigned_with_pubkey(cname, pid, bytes, address)
8176 }
8177 (false, None) => {
8178 return invalid_params(
8179 response_id,
8180 "remote_pubkey_b64 is required for non-inproc transports",
8181 );
8182 }
8183 (false, Some(bytes)) => {
8184 if bytes == [0u8; 32] {
8185 return invalid_params(
8186 response_id,
8187 "remote_pubkey_b64 must be non-zero for non-inproc transports",
8188 );
8189 }
8190 TrustedPeerDescriptor::unsigned_with_pubkey(cname, pid, bytes, address)
8191 }
8192 };
8193
8194 let spec = match spec_result {
8195 Ok(spec) => spec,
8196 Err(err) => {
8197 return invalid_params(response_id, format!("invalid peer spec: {err}"));
8198 }
8199 };
8200
8201 let result = if wire {
8202 runtime
8203 .handle()
8204 .wire(
8205 crate::member_comms_id::mob_member_id(local_id),
8206 PeerTarget::External(spec),
8207 )
8208 .await
8209 } else {
8210 runtime
8211 .handle()
8212 .unwire(
8213 crate::member_comms_id::mob_member_id(local_id),
8214 PeerTarget::External(spec),
8215 )
8216 .await
8217 };
8218
8219 let action = if wire { "wire_local" } else { "unwire_local" };
8220 match result {
8221 Ok(()) => response_value(
8222 response_id,
8223 Some(serde_json::json!({
8224 "accepted": true,
8225 "local_member_id": local_id,
8226 "remote_comms_name": cname,
8227 })),
8228 None,
8229 ),
8230 Err(err) => internal_error(response_id, format!("cross_mob/{action} failed: {err}")),
8231 }
8232}
8233
8234async fn build_live_snapshot(
8235 runtime: &MobRuntime,
8236 config_module_ids: &[String],
8237 console_events: Option<&ConsoleEventStore>,
8238 visibility_policy: &dyn ConsoleVisibilityPolicy,
8239 read_model: &ConsoleSnapshotReadModel,
8240) -> ConsoleLiveSnapshot {
8241 let read_model_state = Box::pin(read_model.snapshot(runtime)).await;
8242 let running = read_model_state.running.unwrap_or(true);
8243 let mut members = read_model_state.primary_members.clone();
8250 if visibility_policy.include_implicit_delegate_members() {
8251 for group in &read_model_state.delegate_member_groups {
8252 members.extend(group.iter().cloned());
8253 }
8254 }
8255 dedupe_console_members_by_identity(&mut members);
8256 members.retain(|member| {
8257 visibility_policy.member_visible(member)
8258 && visibility_policy
8259 .identity_visible(&console_identity_record_from_console_member(member))
8260 });
8261
8262 let loaded_modules = if config_module_ids.is_empty() {
8266 let mut mods: Vec<String> = members
8267 .iter()
8268 .filter(|member| member.state != MEMBER_STATE_RETIRING)
8269 .map(|member| member.agent_identity.clone())
8270 .collect();
8271 mods.sort();
8272 mods
8273 } else {
8274 let mut mods = config_module_ids.to_vec();
8275 mods.sort();
8276 mods
8277 };
8278
8279 let agents = members
8280 .iter()
8281 .map(|member| async move {
8282 let console_identity = console_member_console_identity(member);
8283 let label = member
8284 .labels
8285 .get("display_name")
8286 .cloned()
8287 .unwrap_or_else(|| member.agent_identity.clone());
8288 let watched = member
8289 .labels
8290 .get("console_watched")
8291 .map(|value: &String| value == "true");
8292 let alert_level = member
8293 .labels
8294 .get("console_alert_level")
8295 .filter(|value: &&String| matches!(value.as_str(), "elevated" | "critical"))
8296 .cloned();
8297 let degraded = member
8298 .labels
8299 .get("console_degraded")
8300 .map(|value: &String| value == "true");
8301 let degraded_reason = member.labels.get("console_degraded_reason").cloned();
8302 let response_phase = match console_events {
8303 Some(store) => store.response_phase_for_identity(console_identity).await,
8304 None => None,
8305 };
8306 ConsoleAgentLiveSnapshot {
8307 agent_id: member.agent_identity.clone(),
8308 member_id: member.agent_identity.clone(),
8309 label,
8310 kind: "meerkat".to_string(),
8311 identity: Some(console_identity.to_string()),
8312 role: Some(member.role.clone()),
8313 state: Some(member.state.clone()),
8314 session_id: member.session_id.clone(),
8315 model_capabilities: member.model_capabilities.clone(),
8316 response_phase,
8317 watched,
8318 alert_level,
8319 degraded,
8320 degraded_reason,
8321 }
8322 })
8323 .collect::<Vec<_>>();
8324 let mut agents = join_all(agents).await;
8325 agents.sort_by(|left, right| left.label.cmp(&right.label));
8326 ConsoleLiveSnapshot::new(
8327 Some(runtime.handle().mob_id().to_string()),
8328 running,
8329 loaded_modules,
8330 agents,
8331 members,
8332 true,
8333 )
8334}
8335
8336async fn collect_console_snapshot_read_model(
8337 runtime: &MobRuntime,
8338) -> ConsoleSnapshotReadModelState {
8339 let handle = runtime.handle();
8340 let mut state = ConsoleSnapshotReadModelState {
8341 running: Some(matches!(
8342 handle.status_observation_snapshot(),
8343 MobState::Creating | MobState::Running
8344 )),
8345 ..ConsoleSnapshotReadModelState::default()
8346 };
8347 collect_console_session_index_for_handle(&handle, &mut state).await;
8348 let registered_labels = runtime.console_identity_labels().await;
8349
8350 let (primary_members, _primary_owner_index) =
8356 project_console_members_from_handle(&handle, None, None, &state, ®istered_labels).await;
8357 state.primary_members = primary_members;
8358
8359 let Some(mcp_state) = runtime.agent_mob_mcp_state() else {
8360 return state;
8361 };
8362 let primary_mob_id = handle.mob_id().to_string();
8363 let mut processed = BTreeSet::from([primary_mob_id]);
8364 let mut delegate_groups: Vec<Vec<ConsoleMember>> = Vec::new();
8365 loop {
8366 let mut progressed = false;
8367 for (mob_id, delegate_handle) in Box::pin(mcp_state.mob_handles_snapshot())
8368 .await
8369 .unwrap_or_default()
8370 {
8371 if processed.contains(mob_id.as_str()) {
8372 continue;
8373 }
8374 let Some(owner_authority) = delegate_handle.owner_bridge_session_lifecycle_authority()
8377 else {
8378 processed.insert(mob_id.to_string());
8379 continue;
8380 };
8381 let owner_session_id = owner_authority.bridge_session_id.to_string();
8382 let Some(host_identity) = state
8383 .session_owner_by_id
8384 .get(owner_session_id.as_str())
8385 .cloned()
8386 else {
8387 continue;
8388 };
8389 collect_console_session_index_for_handle(&delegate_handle, &mut state).await;
8390 let (delegate_members, _delegate_owner_index) = project_console_members_from_handle(
8391 &delegate_handle,
8392 Some(&host_identity),
8393 Some(mob_id.as_str()),
8394 &state,
8395 ®istered_labels,
8396 )
8397 .await;
8398 delegate_groups.push(delegate_members);
8399 processed.insert(mob_id.to_string());
8400 progressed = true;
8401 }
8402 if !progressed {
8403 break;
8404 }
8405 }
8406 state.delegate_member_groups = delegate_groups;
8407 state
8408}
8409
8410async fn collect_console_session_index_for_handle(
8411 handle: &MobHandle,
8412 state: &mut ConsoleSnapshotReadModelState,
8413) {
8414 for entry in handle.list_members_observation_snapshot().await {
8415 let identity =
8418 crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
8419 let Some(session_id) = handle
8420 .resolve_bridge_session_id_observation(&entry.agent_identity)
8421 .await
8422 .map(|session_id| session_id.to_string())
8423 else {
8424 state.session_id_by_identity.remove(&identity);
8425 continue;
8426 };
8427 state
8428 .session_owner_by_id
8429 .insert(session_id.clone(), identity.clone());
8430 state.session_id_by_identity.insert(identity, session_id);
8431 }
8432}
8433
8434fn apply_console_visibility_policy(
8435 snapshot: &mut ConsoleLiveSnapshot,
8436 visibility_policy: &dyn ConsoleVisibilityPolicy,
8437) {
8438 let mut hidden = BTreeSet::new();
8439 snapshot.members.retain(|member| {
8440 let visible = visibility_policy.member_visible(member)
8441 && visibility_policy
8442 .identity_visible(&console_identity_record_from_console_member(member));
8443 if !visible {
8444 hidden.insert(member.agent_identity.clone());
8445 }
8446 visible
8447 });
8448 snapshot
8449 .agents
8450 .retain(|agent| !hidden.contains(&agent.agent_id));
8451 snapshot
8452 .loaded_modules
8453 .retain(|module_id| !hidden.contains(module_id));
8454}
8455
8456async fn reset_all_live_console_agents(
8457 runtime: &MobRuntime,
8458 console_events: Option<&ConsoleEventStore>,
8459 console_aggregator: Option<&MobKitConsoleAggregator>,
8460 identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
8461 visibility_policy: &dyn ConsoleVisibilityPolicy,
8462) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
8463 let read_model = ConsoleSnapshotReadModel::default();
8464 *read_model.inner.write().await = Box::pin(collect_console_snapshot_read_model(runtime)).await;
8465 read_model
8468 .primed
8469 .store(true, std::sync::atomic::Ordering::Release);
8470 let snapshot = Box::pin(build_live_snapshot(
8471 runtime,
8472 &[],
8473 console_events,
8474 visibility_policy,
8475 &read_model,
8476 ))
8477 .await;
8478 let raw_snapshot = Box::pin(build_live_snapshot(
8479 runtime,
8480 &[],
8481 console_events,
8482 &crate::console_aggregator::AllowAllConsoleVisibilityPolicy,
8483 &read_model,
8484 ))
8485 .await;
8486 let identity_runtime_statuses = if let Some(identity_runtime) = identity_runtime {
8487 identity_runtime.statuses().await
8488 } else {
8489 Vec::new()
8490 };
8491 let identity_by_runtime_member_id = identity_runtime_statuses
8492 .iter()
8493 .filter_map(|status| {
8494 status
8495 .agent_runtime_id
8496 .as_ref()
8497 .map(|runtime_id| (runtime_id.as_str().to_string(), status.identity.to_string()))
8498 })
8499 .collect::<BTreeMap<_, _>>();
8500 let mut durable_identity_runtime_identities = identity_runtime_statuses
8501 .iter()
8502 .filter(|status| identity_status_visible_to_console(visibility_policy, status))
8503 .map(|status| status.identity.to_string())
8504 .collect::<BTreeSet<_>>();
8505 let mut main_identities = BTreeSet::new();
8506 let mut runtime_member_id_by_identity = BTreeMap::new();
8507 let mut runtime_member_ids_by_identity: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8508 let mut session_id_by_identity_runtime_member: BTreeMap<(String, String), Option<String>> =
8509 BTreeMap::new();
8510 let mut live_alias_by_runtime_member_id: BTreeMap<String, (String, Option<String>)> =
8511 BTreeMap::new();
8512 let mut visible_runtime_member_ids = BTreeSet::new();
8513 let mut duplicate_live_identities = BTreeSet::new();
8514 let mut delegate_members = BTreeSet::new();
8515 for member in snapshot.members {
8516 if member.state == MEMBER_STATE_RETIRING {
8517 continue;
8518 }
8519 if let Some(source_mob_id) = member.labels.get("source_mob_id").cloned() {
8520 delegate_members.insert((source_mob_id, member.agent_identity));
8521 } else {
8522 let identity = member
8523 .labels
8524 .get("agent_identity")
8525 .filter(|value| !value.trim().is_empty())
8526 .cloned()
8527 .or_else(|| {
8528 identity_by_runtime_member_id
8529 .get(&member.agent_identity)
8530 .cloned()
8531 })
8532 .unwrap_or_else(|| member.agent_identity.clone());
8533 if let Some(existing) = runtime_member_id_by_identity.get(&identity)
8534 && existing != &member.agent_identity
8535 {
8536 duplicate_live_identities.insert(identity.clone());
8537 }
8538 runtime_member_ids_by_identity
8539 .entry(identity.clone())
8540 .or_default()
8541 .insert(member.agent_identity.clone());
8542 session_id_by_identity_runtime_member.insert(
8543 (identity.clone(), member.agent_identity.clone()),
8544 member.session_id.clone(),
8545 );
8546 live_alias_by_runtime_member_id.insert(
8547 member.agent_identity.clone(),
8548 (identity.clone(), member.session_id.clone()),
8549 );
8550 visible_runtime_member_ids.insert(member.agent_identity.clone());
8551 runtime_member_id_by_identity
8552 .entry(identity.clone())
8553 .or_insert(member.agent_identity);
8554 main_identities.insert(identity);
8555 }
8556 }
8557 let mut raw_runtime_member_ids_by_identity: BTreeMap<String, BTreeSet<String>> =
8558 BTreeMap::new();
8559 let mut raw_session_id_by_identity_runtime_member: BTreeMap<(String, String), Option<String>> =
8560 BTreeMap::new();
8561 let mut raw_live_alias_by_runtime_member_id: BTreeMap<String, (String, Option<String>)> =
8562 BTreeMap::new();
8563 for member in raw_snapshot.members {
8564 if member.state == MEMBER_STATE_RETIRING || member.labels.contains_key("source_mob_id") {
8565 continue;
8566 }
8567 let identity = member
8568 .labels
8569 .get("agent_identity")
8570 .filter(|value| !value.trim().is_empty())
8571 .cloned()
8572 .or_else(|| {
8573 identity_by_runtime_member_id
8574 .get(&member.agent_identity)
8575 .cloned()
8576 })
8577 .unwrap_or_else(|| member.agent_identity.clone());
8578 raw_runtime_member_ids_by_identity
8579 .entry(identity.clone())
8580 .or_default()
8581 .insert(member.agent_identity.clone());
8582 raw_session_id_by_identity_runtime_member.insert(
8583 (identity.clone(), member.agent_identity.clone()),
8584 member.session_id.clone(),
8585 );
8586 raw_live_alias_by_runtime_member_id
8587 .insert(member.agent_identity, (identity, member.session_id));
8588 }
8589 durable_identity_runtime_identities.retain(|identity| {
8590 identity_runtime_statuses
8591 .iter()
8592 .find(|status| status.identity.as_str() == identity)
8593 .and_then(|status| status.agent_runtime_id.as_ref())
8594 .is_none_or(|runtime_id| {
8595 let runtime_id = runtime_id.as_str();
8596 !raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
8597 || visible_runtime_member_ids.contains(runtime_id)
8598 })
8599 });
8600 let current_main_identities = main_identities.clone();
8601 let baseline_specs = runtime.baseline_member_specs().await;
8602 let baseline_identities = baseline_specs
8603 .iter()
8604 .filter(|spec| baseline_spec_visible_to_console(visibility_policy, spec))
8605 .map(|spec| spec.identity.to_string())
8606 .collect::<BTreeSet<_>>();
8607 main_identities.extend(baseline_identities.iter().cloned());
8608 main_identities.extend(durable_identity_runtime_identities.iter().cloned());
8609
8610 let mut retired_delegates = Vec::new();
8611 let mut reset_main = Vec::new();
8612 let mut retired_delegate_details = Vec::new();
8613 let mut reset_details = Vec::new();
8614 let mut failures = Vec::new();
8615 let mut warnings = Vec::new();
8616
8617 for identity in &main_identities {
8618 let parsed_identity = crate::identity_first::AgentIdentity::parse(identity).ok();
8619 let registered_status = if let (Some(identity_runtime), Some(parsed_identity)) =
8620 (identity_runtime, parsed_identity.as_ref())
8621 {
8622 identity_runtime.status(parsed_identity).await.ok()
8623 } else {
8624 None
8625 };
8626 let baseline_identity_runtime_registered = registered_status.is_some();
8627 if baseline_identities.contains(identity)
8628 && !current_main_identities.contains(identity)
8629 && !baseline_identity_runtime_registered
8630 {
8631 continue;
8632 }
8633 let registered_runtime_id = registered_status
8634 .as_ref()
8635 .and_then(|status| status.agent_runtime_id.as_ref())
8636 .map(crate::identity_first::AgentRuntimeId::as_str);
8637 let registered_visible = registered_runtime_id
8638 .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
8639 let registered_hidden = registered_runtime_id.is_some_and(|runtime_id| {
8640 raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
8641 && !visible_runtime_member_ids.contains(runtime_id)
8642 });
8643 if registered_hidden {
8644 continue;
8645 }
8646 if duplicate_live_identities.contains(identity) && !registered_visible {
8647 failures.push(json!({
8648 "identity": identity,
8649 "error": "ambiguous live identity alias",
8650 }));
8651 continue;
8652 }
8653 if let Some(status) = registered_status.as_ref() {
8654 if let Some(registered_runtime_id) = registered_runtime_id
8655 && let Some((live_identity, _live_session_id)) =
8656 raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
8657 && live_identity != identity
8658 {
8659 failures.push(json!({
8660 "identity": identity,
8661 "error": format!(
8662 "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
8663 ),
8664 "kind": "stale_live_identity_alias",
8665 }));
8666 continue;
8667 }
8668 if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(identity) {
8669 if !registered_runtime_id
8670 .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
8671 {
8672 failures.push(json!({
8673 "identity": identity,
8674 "error": format!(
8675 "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
8676 registered_runtime_id.unwrap_or("<none>"),
8677 live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
8678 ),
8679 "kind": "stale_live_identity_alias",
8680 }));
8681 continue;
8682 }
8683 if let Some(registered_runtime_id) = registered_runtime_id
8684 && let Some(registered_session_id) =
8685 status.session_id.as_ref().map(ToString::to_string)
8686 && let Some(Some(live_session_id)) = raw_session_id_by_identity_runtime_member
8687 .get(&(identity.clone(), registered_runtime_id.to_string()))
8688 && live_session_id != ®istered_session_id
8689 {
8690 failures.push(json!({
8691 "identity": identity,
8692 "error": format!(
8693 "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}"
8694 ),
8695 "kind": "stale_live_identity_alias",
8696 }));
8697 continue;
8698 }
8699 }
8700 if baseline_identities.contains(identity)
8701 && !identity_runtime
8702 .is_some_and(|identity_runtime| identity_runtime.has_session_bridge())
8703 {
8704 failures.push(json!({
8705 "identity": identity,
8706 "error": "reset requires an identity runtime with a session bridge",
8707 "kind": "identity_reset_requires_session_bridge",
8708 }));
8709 }
8710 continue;
8711 }
8712
8713 let runtime_member_id = runtime_member_id_by_identity
8714 .get(identity)
8715 .map(String::as_str)
8716 .unwrap_or(identity.as_str());
8717 if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
8718 && bound_identity != identity
8719 {
8720 failures.push(json!({
8721 "identity": identity,
8722 "error": format!(
8723 "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
8724 ),
8725 "kind": "stale_live_identity_alias",
8726 }));
8727 }
8728 }
8729
8730 if !failures.is_empty() {
8731 return Ok(json!({
8732 "reset": reset_main,
8733 "retired_delegates": retired_delegates,
8734 "reset_details": reset_details,
8735 "retired_delegate_details": retired_delegate_details,
8736 "warnings": warnings,
8737 "failed": failures,
8738 "startup_history": Value::Null,
8739 }));
8740 }
8741
8742 if let Some(state) = runtime.agent_mob_mcp_state() {
8743 for (mob_id, identity) in delegate_members {
8744 match Box::pin(state.handle_for(&MobId::from(mob_id.as_str()))).await {
8745 Ok(handle) => {
8746 match retire_console_member(
8747 &handle,
8748 &crate::member_comms_id::mob_member_id(identity.as_str()),
8749 )
8750 .await
8751 {
8752 Ok(()) => {
8753 let detail = json!({
8754 "identity": identity,
8755 "mob_id": mob_id,
8756 });
8757 retired_delegates.push(detail.clone());
8758 retired_delegate_details.push(detail);
8759 }
8760 Err(err) => failures.push(json!({
8761 "identity": identity,
8762 "mob_id": mob_id,
8763 "error": err,
8764 })),
8765 }
8766 }
8767 Err(err) => failures.push(json!({
8768 "identity": identity,
8769 "mob_id": mob_id,
8770 "error": err.to_string(),
8771 })),
8772 }
8773 }
8774 } else if let Some(aggregator) = console_aggregator {
8775 let identities = delegate_members
8776 .into_iter()
8777 .map(|(_, identity)| identity)
8778 .collect::<BTreeSet<_>>();
8779 for identity in identities {
8780 match Box::pin(aggregator.retire_identity(&identity)).await {
8781 Ok(true) => {
8782 let detail = json!({ "identity": identity });
8783 retired_delegates.push(detail.clone());
8784 retired_delegate_details.push(detail);
8785 }
8786 Ok(false) => failures.push(json!({
8787 "identity": identity,
8788 "error": "unknown identity",
8789 })),
8790 Err(err) => failures.push(json!({
8791 "identity": identity,
8792 "error": err.to_string(),
8793 })),
8794 }
8795 }
8796 }
8797
8798 let handle = runtime.handle();
8799 for spec in baseline_specs {
8800 let identity = spec.identity.to_string();
8801 if !baseline_spec_visible_to_console(visibility_policy, &spec) {
8802 continue;
8803 }
8804 if current_main_identities.contains(&identity) {
8805 continue;
8806 }
8807 if let Some(identity_runtime) = identity_runtime
8808 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8809 && identity_runtime.status(&parsed_identity).await.is_ok()
8810 {
8811 continue;
8812 }
8813 match handle.ensure_member(spec).await {
8814 Ok(_outcome) => {
8815 if let Some(store) = console_events {
8816 store
8817 .record_lifecycle(
8818 &identity,
8819 "identity_reset",
8820 json!({ "scope": "reset_all", "restored": true }),
8821 )
8822 .await;
8823 }
8824 reset_main.push(identity.clone());
8825 reset_details.push(json!({ "identity": identity }));
8826 }
8827 Err(err) => failures.push(json!({
8828 "identity": identity,
8829 "error": err.to_string(),
8830 })),
8831 }
8832 }
8833 for identity in main_identities {
8834 let baseline_identity_runtime_registered = if let Some(identity_runtime) = identity_runtime
8835 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8836 {
8837 identity_runtime.status(&parsed_identity).await.is_ok()
8838 } else {
8839 false
8840 };
8841 if baseline_identities.contains(&identity)
8842 && !current_main_identities.contains(&identity)
8843 && !baseline_identity_runtime_registered
8844 {
8845 continue;
8846 }
8847 let registered_status = if let Some(identity_runtime) = identity_runtime
8848 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8849 {
8850 identity_runtime.status(&parsed_identity).await.ok()
8851 } else {
8852 None
8853 };
8854 let registered_runtime_id = registered_status
8855 .as_ref()
8856 .and_then(|status| status.agent_runtime_id.as_ref())
8857 .map(crate::identity_first::AgentRuntimeId::as_str);
8858 let registered_visible = registered_runtime_id
8859 .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
8860 let registered_hidden = registered_runtime_id.is_some_and(|runtime_id| {
8861 raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
8862 && !visible_runtime_member_ids.contains(runtime_id)
8863 });
8864 if registered_hidden {
8865 continue;
8866 }
8867 if duplicate_live_identities.contains(&identity) && !registered_visible {
8868 failures.push(json!({
8869 "identity": identity,
8870 "error": "ambiguous live identity alias",
8871 }));
8872 continue;
8873 }
8874 if baseline_identities.contains(&identity) {
8875 if let Some(identity_runtime) = identity_runtime
8876 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
8877 && let Ok(status) = identity_runtime.status(&parsed_identity).await
8878 {
8879 let registered_runtime_id = status
8880 .agent_runtime_id
8881 .as_ref()
8882 .map(crate::identity_first::AgentRuntimeId::as_str);
8883 if let Some(registered_runtime_id) = registered_runtime_id
8884 && let Some((live_identity, _live_session_id)) =
8885 raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
8886 && live_identity != &identity
8887 {
8888 failures.push(json!({
8889 "identity": identity,
8890 "error": format!(
8891 "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
8892 ),
8893 "kind": "stale_live_identity_alias",
8894 }));
8895 continue;
8896 }
8897 if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(&identity) {
8898 if !registered_runtime_id
8899 .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
8900 {
8901 failures.push(json!({
8902 "identity": identity,
8903 "error": format!(
8904 "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
8905 registered_runtime_id.unwrap_or("<none>"),
8906 live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
8907 ),
8908 "kind": "stale_live_identity_alias",
8909 }));
8910 continue;
8911 }
8912 if let Some(registered_runtime_id) = registered_runtime_id
8913 && let Some(registered_session_id) =
8914 status.session_id.as_ref().map(ToString::to_string)
8915 && let Some(Some(live_session_id)) =
8916 raw_session_id_by_identity_runtime_member
8917 .get(&(identity.clone(), registered_runtime_id.to_string()))
8918 && live_session_id != ®istered_session_id
8919 {
8920 failures.push(json!({
8921 "identity": identity,
8922 "error": format!(
8923 "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}"
8924 ),
8925 "kind": "stale_live_identity_alias",
8926 }));
8927 continue;
8928 }
8929 }
8930 if !identity_runtime.has_session_bridge() {
8931 failures.push(json!({
8932 "identity": identity,
8933 "error": "reset requires an identity runtime with a session bridge",
8934 "kind": "identity_reset_requires_session_bridge",
8935 }));
8936 continue;
8937 }
8938 match identity_runtime.reset(&parsed_identity).await {
8939 Ok(record) => {
8940 let cleanup_warning = json!({
8941 "identity": identity,
8942 "kind": "stale_member_cleanup_skipped_after_identity_reset",
8943 "message": "reset published the new generation without retiring stale live mob members; identity control calls reject stale runtime ids",
8944 });
8945 warnings.push(cleanup_warning);
8946 reset_details.push(json!({
8947 "identity": identity,
8948 "cleanup_warning": warnings.last().cloned(),
8949 }));
8950 reset_main.push(identity);
8951 if let Some(store) = console_events {
8952 store
8953 .record_lifecycle(
8954 parsed_identity.as_str(),
8955 "identity_reset",
8956 json!({
8957 "scope": "reset_all",
8958 "generation": record.generation.get(),
8959 "checkpoint_version": record.checkpoint_version.get(),
8960 "cleanup_warning": warnings.last().cloned(),
8961 }),
8962 )
8963 .await;
8964 }
8965 }
8966 Err(err) => failures.push(json!({
8967 "identity": identity,
8968 "error": err.to_string(),
8969 })),
8970 }
8971 continue;
8972 }
8973 let runtime_member_id = runtime_member_id_by_identity
8974 .get(&identity)
8975 .map(String::as_str)
8976 .unwrap_or(identity.as_str());
8977 if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
8978 && bound_identity != &identity
8979 {
8980 failures.push(json!({
8981 "identity": identity,
8982 "error": format!(
8983 "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
8984 ),
8985 "kind": "stale_live_identity_alias",
8986 }));
8987 continue;
8988 }
8989 match Box::pin(respawn_console_member(
8990 &handle,
8991 &crate::member_comms_id::mob_member_id(runtime_member_id),
8992 ))
8993 .await
8994 {
8995 Ok(topology_restore_warning) => {
8996 if let Some(store) = console_events {
8997 store
8998 .record_lifecycle(
8999 &identity,
9000 "identity_reset",
9001 json!({
9002 "scope": "reset_all",
9003 "topology_restore_warning": topology_restore_warning.clone(),
9004 }),
9005 )
9006 .await;
9007 }
9008 reset_main.push(identity.clone());
9009 let mut detail = json!({ "identity": identity });
9010 if let Some(warning) = topology_restore_warning {
9011 detail["topology_restore_warning"] = warning;
9012 }
9013 reset_details.push(detail);
9014 }
9015 Err(err) => failures.push(json!({
9016 "identity": identity,
9017 "error": err,
9018 })),
9019 }
9020 } else {
9021 if let Some(identity_runtime) = identity_runtime
9022 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
9023 && let Ok(registered_status) = identity_runtime.status(&parsed_identity).await
9024 {
9025 let registered_runtime_id = registered_status
9026 .agent_runtime_id
9027 .as_ref()
9028 .map(crate::identity_first::AgentRuntimeId::as_str);
9029 let registered_visible = registered_runtime_id
9030 .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
9031 if duplicate_live_identities.contains(&identity) && !registered_visible {
9032 failures.push(json!({
9033 "identity": identity,
9034 "error": "ambiguous live identity alias",
9035 }));
9036 continue;
9037 }
9038 if let Some(registered_runtime_id) = registered_runtime_id
9039 && let Some((live_identity, _live_session_id)) =
9040 raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
9041 && live_identity != &identity
9042 {
9043 failures.push(json!({
9044 "identity": identity,
9045 "error": format!(
9046 "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
9047 ),
9048 "kind": "stale_live_identity_alias",
9049 }));
9050 continue;
9051 }
9052 if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(&identity) {
9053 if !registered_runtime_id
9054 .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
9055 {
9056 failures.push(json!({
9057 "identity": identity,
9058 "error": format!(
9059 "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
9060 registered_runtime_id.unwrap_or("<none>"),
9061 live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
9062 ),
9063 "kind": "stale_live_identity_alias",
9064 }));
9065 continue;
9066 }
9067 if let Some(registered_runtime_id) = registered_runtime_id
9068 && let Some(registered_session_id) = registered_status
9069 .session_id
9070 .as_ref()
9071 .map(ToString::to_string)
9072 && let Some(Some(live_session_id)) =
9073 raw_session_id_by_identity_runtime_member
9074 .get(&(identity.clone(), registered_runtime_id.to_string()))
9075 && live_session_id != ®istered_session_id
9076 {
9077 failures.push(json!({
9078 "identity": identity,
9079 "error": format!(
9080 "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}"
9081 ),
9082 "kind": "stale_live_identity_alias",
9083 }));
9084 continue;
9085 }
9086 }
9087 match identity_runtime.retire(&parsed_identity).await {
9088 Ok(token) => {
9089 let keep_runtime_member_id = registered_status
9090 .agent_runtime_id
9091 .as_ref()
9092 .filter(|_| identity_runtime.has_session_bridge())
9093 .map(crate::identity_first::AgentRuntimeId::as_str);
9094 match retire_stale_console_members_for_identity(
9095 &handle,
9096 visibility_policy,
9097 parsed_identity.as_str(),
9098 keep_runtime_member_id,
9099 )
9100 .await
9101 {
9102 Ok(()) => {
9103 retired_delegate_details.push(json!({ "identity": identity }));
9104 retired_delegates.push(json!({ "identity": identity }));
9105 if let Some(store) = console_events {
9106 store
9107 .record_lifecycle(
9108 parsed_identity.as_str(),
9109 "identity_retired",
9110 json!({
9111 "scope": "reset_all",
9112 "dynamic": true,
9113 "fencing_token": token.get(),
9114 }),
9115 )
9116 .await;
9117 }
9118 }
9119 Err(err) => {
9120 warnings.push(json!({
9121 "identity": identity,
9122 "kind": "stale_member_cleanup_failed_after_identity_retire",
9123 "message": err,
9124 }));
9125 retired_delegate_details.push(json!({
9126 "identity": identity,
9127 "cleanup_warning": warnings.last().cloned(),
9128 }));
9129 retired_delegates.push(json!({ "identity": identity }));
9130 if let Some(store) = console_events {
9131 store
9132 .record_lifecycle(
9133 parsed_identity.as_str(),
9134 "identity_retired",
9135 json!({
9136 "scope": "reset_all",
9137 "dynamic": true,
9138 "fencing_token": token.get(),
9139 "cleanup_warning": warnings.last().cloned(),
9140 }),
9141 )
9142 .await;
9143 }
9144 }
9145 }
9146 }
9147 Err(err) => failures.push(json!({
9148 "identity": identity,
9149 "error": err.to_string(),
9150 })),
9151 }
9152 continue;
9153 }
9154 let runtime_member_id = runtime_member_id_by_identity
9155 .get(&identity)
9156 .map(String::as_str)
9157 .unwrap_or(identity.as_str());
9158 if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
9159 && bound_identity != &identity
9160 {
9161 failures.push(json!({
9162 "identity": identity,
9163 "error": format!(
9164 "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
9165 ),
9166 "kind": "stale_live_identity_alias",
9167 }));
9168 continue;
9169 }
9170 match retire_console_member(
9171 &handle,
9172 &crate::member_comms_id::mob_member_id(runtime_member_id),
9173 )
9174 .await
9175 {
9176 Ok(()) => {
9177 if let Some(store) = console_events {
9178 store
9179 .record_lifecycle(
9180 &identity,
9181 "identity_retired",
9182 json!({ "scope": "reset_all", "dynamic": true }),
9183 )
9184 .await;
9185 }
9186 retired_delegates.push(json!({ "identity": identity }));
9187 retired_delegate_details.push(json!({ "identity": identity }));
9188 }
9189 Err(err) => failures.push(json!({
9190 "identity": identity,
9191 "error": err,
9192 })),
9193 }
9194 }
9195 }
9196
9197 let startup_history = if failures.is_empty() {
9198 if let Some(aggregator) = console_aggregator {
9199 Box::pin(wait_for_reset_startup_history(
9200 aggregator,
9201 reset_main.iter().cloned().collect::<BTreeSet<_>>(),
9202 Duration::from_secs(10),
9203 ))
9204 .await
9205 .unwrap_or_else(|err| json!({ "error": err.to_string() }))
9206 } else {
9207 Value::Null
9208 }
9209 } else {
9210 Value::Null
9211 };
9212
9213 Ok(json!({
9214 "reset": reset_main,
9215 "retired_delegates": retired_delegates,
9216 "reset_details": reset_details,
9217 "retired_delegate_details": retired_delegate_details,
9218 "warnings": warnings,
9219 "failed": failures,
9220 "startup_history": startup_history,
9221 }))
9222}
9223
9224async fn wait_for_reset_startup_history(
9225 aggregator: &MobKitConsoleAggregator,
9226 identities: BTreeSet<String>,
9227 timeout: Duration,
9228) -> ConsoleLogResult<Value> {
9229 if identities.is_empty() {
9230 return Ok(json!({
9231 "timeout": false,
9232 "ready": Vec::<String>::new(),
9233 "pending": Vec::<String>::new(),
9234 }));
9235 }
9236
9237 let deadline = Instant::now() + timeout;
9238 let mut pending = identities;
9239 let mut ready = BTreeSet::new();
9240 while !pending.is_empty() {
9241 for identity in pending.clone() {
9242 let page = Box::pin(aggregator.query_timeline(ConsoleTimelineQuery {
9243 identity: Some(identity.clone()),
9244 limit: 1000,
9245 ..ConsoleTimelineQuery::default()
9246 }))
9247 .await?;
9248 let startup_completed = page.frames.iter().any(|frame| {
9249 matches!(
9250 frame.kind.as_str(),
9251 "interaction_complete" | "turn_completed"
9252 )
9253 });
9254 if startup_completed {
9255 pending.remove(&identity);
9256 ready.insert(identity);
9257 }
9258 }
9259
9260 if pending.is_empty() {
9261 break;
9262 }
9263 if Instant::now() >= deadline {
9264 return Ok(json!({
9265 "timeout": true,
9266 "ready": ready.into_iter().collect::<Vec<_>>(),
9267 "pending": pending.into_iter().collect::<Vec<_>>(),
9268 }));
9269 }
9270 tokio::time::sleep(Duration::from_millis(250)).await;
9271 }
9272
9273 Ok(json!({
9274 "timeout": false,
9275 "ready": ready.into_iter().collect::<Vec<_>>(),
9276 "pending": Vec::<String>::new(),
9277 }))
9278}
9279
9280fn dedupe_console_members_by_identity(members: &mut Vec<ConsoleMember>) {
9281 let mut seen_member_ids = BTreeSet::new();
9282 members.retain(|member| seen_member_ids.insert(member.agent_identity.clone()));
9283}
9284
9285fn console_member_console_identity(member: &ConsoleMember) -> &str {
9286 member
9287 .labels
9288 .get("agent_identity")
9289 .filter(|value| !value.trim().is_empty())
9290 .map_or(member.agent_identity.as_str(), String::as_str)
9291}
9292
9293fn console_identity_record_from_console_member(member: &ConsoleMember) -> ConsoleIdentityRecord {
9294 let identity = console_member_console_identity(member).to_string();
9295 let addressable = member
9296 .labels
9297 .get("addressable")
9298 .map(|value| !value.eq_ignore_ascii_case("false"))
9299 .unwrap_or(true)
9300 && member.state == MEMBER_STATE_ACTIVE;
9301 let visibility = if member.state == MEMBER_STATE_RETIRING {
9302 ConsoleVisibility::RetiredReadable
9303 } else if addressable {
9304 ConsoleVisibility::Addressable
9305 } else {
9306 ConsoleVisibility::Hidden
9307 };
9308 ConsoleIdentityRecord {
9309 identity: identity.clone(),
9310 display_name: member
9311 .labels
9312 .get("display_name")
9313 .cloned()
9314 .unwrap_or(identity),
9315 runtime_key: "runtime".to_string(),
9316 runtime_member_id: member.agent_identity.clone(),
9317 session_id: member.session_id.clone(),
9318 visibility,
9319 addressable,
9320 health: member.state.clone(),
9321 topology_peers: member.wired_to.clone(),
9322 labels: member.labels.clone(),
9323 }
9324}
9325
9326fn baseline_spec_visible_to_console(
9327 visibility_policy: &dyn ConsoleVisibilityPolicy,
9328 spec: &SpawnMemberSpec,
9329) -> bool {
9330 let mut labels = spec.labels.clone().unwrap_or_default();
9331 labels
9332 .entry("role".to_string())
9333 .or_insert_with(|| spec.role_name.to_string());
9334 let record = ConsoleIdentityRecord {
9335 identity: spec.identity.to_string(),
9336 display_name: spec.identity.to_string(),
9337 runtime_key: "baseline".to_string(),
9338 runtime_member_id: spec.identity.to_string(),
9339 session_id: None,
9340 visibility: ConsoleVisibility::Addressable,
9341 addressable: true,
9342 health: "baseline".to_string(),
9343 topology_peers: Vec::new(),
9344 labels,
9345 };
9346 let member = ConsoleMember {
9347 agent_identity: spec.identity.to_string(),
9348 role: spec.role_name.to_string(),
9349 state: MEMBER_STATE_ACTIVE.to_string(),
9350 model_capabilities: ConsoleModelCapabilities::default(),
9351 runtime_mode: spec
9352 .runtime_mode
9353 .as_ref()
9354 .map(std::string::ToString::to_string),
9355 session_id: None,
9356 wired_to: Vec::new(),
9357 labels: record.labels.clone(),
9358 progress: None,
9359 };
9360 visibility_policy.member_visible(&member) && visibility_policy.identity_visible(&record)
9361}
9362
9363pub(crate) async fn prime_access_cache_from_runtime(
9377 runtime: &MobRuntime,
9378 access: &AccessController,
9379) {
9380 if !access.enabled() {
9381 return;
9382 }
9383 let registered = runtime.console_identity_labels().await;
9384 prime_access_cache_from_handle_with_registry(&runtime.handle(), access, ®istered).await;
9385}
9386
9387pub(crate) async fn prime_access_cache_from_handle(handle: &MobHandle, access: &AccessController) {
9388 if !access.enabled() {
9389 return;
9390 }
9391 prime_access_cache_from_handle_with_registry(handle, access, &BTreeMap::new()).await;
9392}
9393
9394async fn prime_access_cache_from_handle_with_registry(
9395 handle: &MobHandle,
9396 access: &AccessController,
9397 registered: &BTreeMap<String, BTreeMap<String, String>>,
9398) {
9399 let mut registry_only: BTreeMap<&String, &BTreeMap<String, String>> =
9400 registered.iter().collect();
9401 for entry in handle.list_all_members().await {
9402 let member_identity =
9405 crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
9406 let console_identity = entry
9409 .labels
9410 .get("agent_identity")
9411 .map(|value| value.trim())
9412 .filter(|value| !value.is_empty())
9413 .map_or_else(|| member_identity.clone(), ToString::to_string);
9414 let mut labels = entry.labels.clone();
9415 crate::console_spawn::sanitize_unverified_lineage_labels(&mut labels);
9419 if let Some(spawn_labels) = registry_only.remove(&console_identity) {
9420 crate::console_spawn::merge_registered_labels(&mut labels, spawn_labels);
9421 }
9422 access.record_agent_attributes(AgentResourceAttributes {
9423 identity: console_identity,
9424 agent_id: Some(member_identity),
9425 role: Some(entry.role.to_string()),
9426 labels,
9427 });
9428 }
9429 for (identity, spawn_labels) in registry_only {
9430 access.record_agent_attributes(AgentResourceAttributes {
9431 identity: identity.clone(),
9432 agent_id: None,
9433 role: spawn_labels.get("role").cloned(),
9434 labels: spawn_labels.clone(),
9435 });
9436 }
9437}
9438
9439async fn project_console_members_from_handle(
9440 handle: &MobHandle,
9441 host_identity: Option<&str>,
9442 source_mob_id: Option<&str>,
9443 read_model: &ConsoleSnapshotReadModelState,
9444 registered_labels: &BTreeMap<String, BTreeMap<String, String>>,
9445) -> (Vec<ConsoleMember>, BTreeMap<String, String>) {
9446 let entries = handle.list_members_including_retiring().await;
9450 let mut members = Vec::with_capacity(entries.len());
9451 let mut session_owner_by_id = BTreeMap::new();
9452 for entry in &entries {
9453 let identity =
9456 crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
9457 let session_id = read_model.session_id_by_identity.get(&identity).cloned();
9458 if let Some(session_id) = session_id.as_ref() {
9459 session_owner_by_id.insert(session_id.clone(), identity.clone());
9460 }
9461 let model_capabilities =
9462 model_capabilities_for_role(handle.definition(), entry.role.as_str());
9463 let mut labels = entry.labels.clone();
9464 crate::console_spawn::sanitize_unverified_lineage_labels(&mut labels);
9469 let console_identity_key = entry
9470 .labels
9471 .get("agent_identity")
9472 .map(|value| value.trim())
9473 .filter(|value| !value.is_empty())
9474 .map_or(identity.as_str(), |value| value);
9475 if let Some(spawn_labels) = registered_labels.get(console_identity_key) {
9476 crate::console_spawn::merge_registered_labels(&mut labels, spawn_labels);
9477 }
9478 if let Some(host_identity) = host_identity {
9479 labels
9480 .entry("delegate_host_identity".to_string())
9481 .or_insert_with(|| host_identity.to_string());
9482 labels
9483 .entry("group".to_string())
9484 .or_insert_with(|| "Coordinators".to_string());
9485 }
9486 if let Some(source_mob_id) = source_mob_id {
9487 labels
9488 .entry("source_mob_id".to_string())
9489 .or_insert_with(|| source_mob_id.to_string());
9490 }
9491 let mut wired_to: Vec<String> = entry
9492 .wired_to
9493 .iter()
9494 .map(|peer| crate::member_comms_id::runtime_alias_str(peer.as_str()).into_owned())
9495 .collect();
9496 if let Some(host_identity) = host_identity
9497 && !wired_to.iter().any(|peer| peer == host_identity)
9498 {
9499 wired_to.push(host_identity.to_string());
9500 }
9501 members.push(ConsoleMember {
9502 agent_identity: identity,
9503 role: entry.role.to_string(),
9504 state: member_status_state_string(entry.status),
9505 model_capabilities,
9506 runtime_mode: Some(entry.runtime_mode.to_string()),
9507 session_id,
9508 wired_to,
9509 labels,
9510 progress: None,
9511 });
9512 }
9513 attach_member_progress(handle, &entries, &mut members).await;
9514 (members, session_owner_by_id)
9515}
9516
9517const CONSOLE_PROGRESS_MEMBER_CAP: usize = 64;
9525
9526fn console_progress_member_cap() -> usize {
9527 std::env::var("MOBKIT_CONSOLE_PROGRESS_MEMBER_CAP")
9528 .ok()
9529 .and_then(|value| value.trim().parse::<usize>().ok())
9530 .unwrap_or(CONSOLE_PROGRESS_MEMBER_CAP)
9531}
9532
9533async fn attach_member_progress(
9537 handle: &MobHandle,
9538 entries: &[meerkat_mob::runtime::MobMemberListEntry],
9539 members: &mut [ConsoleMember],
9540) {
9541 let cap = console_progress_member_cap();
9542 if entries.len() > cap {
9543 tracing::debug!(
9544 member_count = entries.len(),
9545 cap,
9546 "skipping console progress projection: roster exceeds \
9547 MOBKIT_CONSOLE_PROGRESS_MEMBER_CAP"
9548 );
9549 return;
9550 }
9551 for (entry, member) in entries.iter().zip(members.iter_mut()) {
9552 if entry.is_final {
9553 continue;
9554 }
9555 let Ok(snapshot) = handle.member_status(&entry.agent_identity).await else {
9556 continue;
9557 };
9558 member.progress = snapshot
9559 .progress
9560 .as_ref()
9561 .and_then(|progress| serde_json::to_value(progress).ok());
9562 }
9563}
9564
9565async fn build_aggregator_live_snapshot(
9566 aggregator: &MobKitConsoleAggregator,
9567 config_module_ids: &[String],
9568) -> Result<ConsoleLiveSnapshot, Box<dyn std::error::Error + Send + Sync>> {
9569 let identities = Box::pin(aggregator.list_identities()).await?;
9570 let mut members = Vec::with_capacity(identities.len());
9571 for identity in &identities {
9572 let mut labels = identity.labels.clone();
9573 labels
9574 .entry("display_name".to_string())
9575 .or_insert_with(|| identity.display_name.clone());
9576 labels
9577 .entry("addressable".to_string())
9578 .or_insert_with(|| identity.addressable.to_string());
9579 let wired_to = identity.topology_peers.clone();
9583 members.push(ConsoleMember {
9584 agent_identity: identity.identity.clone(),
9585 role: labels
9586 .get("role")
9587 .cloned()
9588 .unwrap_or_else(|| "identity".to_string()),
9589 state: identity.health.clone(),
9590 model_capabilities: ConsoleModelCapabilities::default(),
9591 runtime_mode: Some("console_aggregator".to_string()),
9592 session_id: identity.session_id.clone(),
9593 wired_to,
9594 labels,
9595 progress: None,
9596 });
9597 }
9598 members.sort_by(|left, right| left.agent_identity.cmp(&right.agent_identity));
9599 let agents = members
9600 .iter()
9601 .map(|member| ConsoleAgentLiveSnapshot {
9602 agent_id: member.agent_identity.clone(),
9603 member_id: member.agent_identity.clone(),
9604 label: member
9605 .labels
9606 .get("display_name")
9607 .cloned()
9608 .unwrap_or_else(|| member.agent_identity.clone()),
9609 kind: "meerkat".to_string(),
9610 identity: Some(member.agent_identity.clone()),
9611 role: Some(member.role.clone()),
9612 state: Some(member.state.clone()),
9613 session_id: member.session_id.clone(),
9614 model_capabilities: member.model_capabilities.clone(),
9615 response_phase: None,
9616 watched: None,
9617 alert_level: None,
9618 degraded: None,
9619 degraded_reason: None,
9620 })
9621 .collect::<Vec<_>>();
9622 let loaded_modules = if config_module_ids.is_empty() {
9623 members
9624 .iter()
9625 .map(|member| member.agent_identity.clone())
9626 .collect()
9627 } else {
9628 config_module_ids.to_vec()
9629 };
9630 Ok(ConsoleLiveSnapshot::new(
9631 Some("console-aggregator".to_string()),
9632 true,
9633 loaded_modules,
9634 agents,
9635 members,
9636 true,
9637 ))
9638}
9639
9640pub async fn console_frontend_index_handler() -> impl IntoResponse {
9641 (
9642 [
9643 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
9644 (header::CACHE_CONTROL, "no-store"),
9645 ],
9646 CONSOLE_FRONTEND_INDEX_HTML,
9647 )
9648}
9649
9650pub async fn console_frontend_app_js_handler() -> impl IntoResponse {
9651 (
9652 [
9653 (
9654 header::CONTENT_TYPE,
9655 "application/javascript; charset=utf-8",
9656 ),
9657 (header::CACHE_CONTROL, "no-store"),
9658 ],
9659 CONSOLE_FRONTEND_APP_JS,
9660 )
9661}
9662
9663pub async fn console_frontend_app_css_handler() -> impl IntoResponse {
9664 (
9665 [
9666 (header::CONTENT_TYPE, "text/css; charset=utf-8"),
9667 (header::CACHE_CONTROL, "no-store"),
9668 ],
9669 CONSOLE_FRONTEND_APP_CSS,
9670 )
9671}
9672
9673#[cfg(test)]
9674#[allow(clippy::expect_used, clippy::large_futures)]
9675mod tests {
9676 use super::ConsoleTimelineHttpQuery;
9677 use super::{
9678 ConsoleSnapshotReadModel, ConsoleSnapshotReadModelState, MAX_MULTIPART_BODY_BYTES,
9679 MAX_MULTIPART_IMAGE_BYTES, MultipartImageUpload, apply_console_visibility_policy,
9680 build_aggregator_live_snapshot, collect_console_snapshot_read_model,
9681 console_send_identity_first, console_send_with_identity_first_fallback,
9682 console_timeline_replay_unavailable_response, cursor_is_after,
9683 dedupe_console_members_by_identity, externalize_image_upload_placeholders,
9684 externalize_single_image_upload, handle_console_aggregator_rpc, handle_console_runtime_rpc,
9685 handle_console_runtime_rpc_with_visibility, member_id_matches_durable_identity,
9686 project_console_members_from_handle, query_timeline_snapshot, timeline_query_from_http,
9687 };
9688 use crate::access::{
9689 ACTION_AGENT_MEMORY_DELETE, ACTION_AGENT_MEMORY_READ, ACTION_AGENT_MEMORY_WRITE,
9690 ACTION_AGENT_SEND, ACTION_AGENT_VIEW, AccessController,
9691 };
9692 use crate::blob_store::{BinaryBlobStore, ObjectStoreBlobStore};
9693 use crate::console_aggregator::{
9694 AllowAllConsoleVisibilityPolicy, ConsoleIdentityRecord,
9695 HideImplicitDelegateMembersConsoleVisibilityPolicy,
9696 };
9697 use crate::console_aggregator::{
9698 ConsoleCursor, ConsoleFrameSource, ConsoleFrameSourceKind, ConsoleFrameStatus,
9699 ConsoleTimelineQuery, ConsoleTimelineWindowQuery, ConsoleVisibilityPolicy,
9700 MobKitConsoleAggregator, NewConsoleFrame,
9701 };
9702 use crate::identity_first::contracts::{ContinuityStore, LeaseProvider};
9703 use crate::identity_first::{
9704 AgentAddressability, AgentBuildDraft, AgentIdentity, AgentMemoryConfig,
9705 AgentMemoryRuntimeInjector, AgentRuntimeId, BridgeError, CheckpointVersion,
9706 ContinuityGeneration, ContinuityRecord, DurabilityPolicy, DurableAgentSpec, FencingToken,
9707 IdentityLifecycleState, IdentityRuntime, IdentityRuntimeConfig, LeaseAcquireResult,
9708 LeaseGrant, LocalContinuityStore, LocalLeaseProvider, ManagedPeerEdge,
9709 MarkdownAgentMemoryStore, ResumeSessionOutcome, SessionBridge, SessionSnapshot,
9710 };
9711 use crate::mob_handle_runtime::{MobRuntime, model_capabilities_for_role};
9712 use crate::rpc::{JSONRPC_VERSION, JsonRpcRequest};
9713 use crate::runtime::{ConsoleAgentLiveSnapshot, ConsoleLiveSnapshot, ConsoleMember};
9714 use crate::unified_runtime::ConsoleEventStore;
9715 use crate::{MobBootstrapOptions, MobBootstrapSpec};
9716 use bytes::Bytes;
9717 use meerkat::{AgentFactory, Config, build_ephemeral_service};
9718 use meerkat_client::TestClient;
9719 use meerkat_core::types::HandlingMode;
9720 use meerkat_mob::ProfileName;
9721 use meerkat_mob::{MobDefinition, MobStorage, SpawnMemberSpec};
9722 use serde_json::{Value, json};
9723 use std::collections::BTreeMap;
9724 use std::sync::atomic::{AtomicUsize, Ordering};
9725 use std::sync::{Arc, Mutex};
9726 use std::time::Duration;
9727
9728 struct BlockingIdentityBridge {
9729 deliver_calls: Arc<AtomicUsize>,
9730 }
9731
9732 struct RecordingIdentityBridge {
9733 session_id: meerkat_core::types::SessionId,
9734 handling_modes: Arc<Mutex<Vec<HandlingMode>>>,
9735 }
9736
9737 #[async_trait::async_trait]
9738 impl SessionBridge for BlockingIdentityBridge {
9739 async fn create_session(
9740 &self,
9741 _identity: &AgentIdentity,
9742 _runtime_id: &AgentRuntimeId,
9743 _spec: &DurableAgentSpec,
9744 _draft: &AgentBuildDraft,
9745 session_id: &meerkat_core::types::SessionId,
9746 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
9747 Ok(session_id.clone())
9748 }
9749
9750 async fn resume_session(
9751 &self,
9752 _identity: &AgentIdentity,
9753 _runtime_id: &AgentRuntimeId,
9754 _spec: &DurableAgentSpec,
9755 _draft: &AgentBuildDraft,
9756 session_id: &meerkat_core::types::SessionId,
9757 _snapshot: &SessionSnapshot,
9758 ) -> Result<ResumeSessionOutcome, BridgeError> {
9759 Ok(ResumeSessionOutcome::Resumed {
9760 session_id: session_id.clone(),
9761 })
9762 }
9763
9764 async fn deliver(
9765 &self,
9766 _runtime_id: &AgentRuntimeId,
9767 _content: &meerkat_core::ContentInput,
9768 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
9769 self.deliver_calls.fetch_add(1, Ordering::SeqCst);
9770 std::future::pending().await
9771 }
9772
9773 async fn checkpoint_session(
9774 &self,
9775 _runtime_id: &AgentRuntimeId,
9776 _session_id: &meerkat_core::types::SessionId,
9777 ) -> Result<SessionSnapshot, BridgeError> {
9778 Err(BridgeError::Mob("checkpoint not used in test".to_string()))
9779 }
9780
9781 async fn retire_member(&self, _runtime_id: &AgentRuntimeId) -> Result<(), BridgeError> {
9782 Ok(())
9783 }
9784 }
9785
9786 #[async_trait::async_trait]
9787 impl SessionBridge for RecordingIdentityBridge {
9788 async fn create_session(
9789 &self,
9790 _identity: &AgentIdentity,
9791 _runtime_id: &AgentRuntimeId,
9792 _spec: &DurableAgentSpec,
9793 _draft: &AgentBuildDraft,
9794 session_id: &meerkat_core::types::SessionId,
9795 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
9796 Ok(session_id.clone())
9797 }
9798
9799 async fn resume_session(
9800 &self,
9801 _identity: &AgentIdentity,
9802 _runtime_id: &AgentRuntimeId,
9803 _spec: &DurableAgentSpec,
9804 _draft: &AgentBuildDraft,
9805 session_id: &meerkat_core::types::SessionId,
9806 _snapshot: &SessionSnapshot,
9807 ) -> Result<ResumeSessionOutcome, BridgeError> {
9808 Ok(ResumeSessionOutcome::Resumed {
9809 session_id: session_id.clone(),
9810 })
9811 }
9812
9813 async fn deliver(
9814 &self,
9815 runtime_id: &AgentRuntimeId,
9816 content: &meerkat_core::ContentInput,
9817 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
9818 self.deliver_with_mode(runtime_id, content, HandlingMode::Queue)
9819 .await
9820 }
9821
9822 async fn deliver_with_mode(
9823 &self,
9824 _runtime_id: &AgentRuntimeId,
9825 _content: &meerkat_core::ContentInput,
9826 handling_mode: HandlingMode,
9827 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
9828 self.handling_modes
9829 .lock()
9830 .map_err(|_| BridgeError::Mob("handling modes mutex poisoned".to_string()))?
9831 .push(handling_mode);
9832 Ok(self.session_id.clone())
9833 }
9834
9835 async fn checkpoint_session(
9836 &self,
9837 _runtime_id: &AgentRuntimeId,
9838 _session_id: &meerkat_core::types::SessionId,
9839 ) -> Result<SessionSnapshot, BridgeError> {
9840 Err(BridgeError::Mob("checkpoint not used in test".to_string()))
9841 }
9842
9843 async fn retire_member(&self, _runtime_id: &AgentRuntimeId) -> Result<(), BridgeError> {
9844 Ok(())
9845 }
9846 }
9847
9848 async fn build_empty_console_test_runtime(
9849 mob_id: &str,
9850 ) -> Result<(tempfile::TempDir, MobRuntime), Box<dyn std::error::Error + Send + Sync>> {
9851 let temp_dir = tempfile::tempdir()?;
9852 let session_path = temp_dir.path().join("sessions");
9853 std::fs::create_dir_all(&session_path)?;
9854 let factory = AgentFactory::new(&session_path).comms(true);
9855 let session_service = Arc::new(build_ephemeral_service(factory, Config::default(), 16));
9856 let definition = MobDefinition::from_toml(&format!(
9857 r#"
9858[mob]
9859id = "{mob_id}"
9860
9861[profiles.worker]
9862model = "gpt-5.5"
9863external_addressable = true
9864
9865[profiles.worker.tools]
9866comms = true
9867"#
9868 ))?;
9869 let runtime = MobRuntime::bootstrap(
9870 MobBootstrapSpec::new(definition, MobStorage::in_memory(), session_service)
9871 .with_options(MobBootstrapOptions {
9872 allow_ephemeral_sessions: true,
9873 notify_orchestrator_on_resume: true,
9874 default_llm_client: Some(Arc::new(TestClient::default())),
9875 }),
9876 )
9877 .await?;
9878 Ok((temp_dir, runtime))
9879 }
9880
9881 fn rpc_request(method: &str) -> JsonRpcRequest {
9882 JsonRpcRequest {
9883 jsonrpc: JSONRPC_VERSION.to_string(),
9884 id: Some(json!(1)),
9885 method: method.to_string(),
9886 params: json!({}),
9887 }
9888 }
9889
9890 fn rpc_request_with_params(method: &str, params: Value) -> JsonRpcRequest {
9891 JsonRpcRequest {
9892 jsonrpc: JSONRPC_VERSION.to_string(),
9893 id: Some(json!(1)),
9894 method: method.to_string(),
9895 params,
9896 }
9897 }
9898
9899 #[tokio::test]
9900 async fn read_only_aggregator_capabilities_omit_mutating_methods() {
9901 let response = handle_console_aggregator_rpc(
9902 None,
9903 rpc_request("mobkit/capabilities"),
9904 true,
9905 true,
9906 None,
9907 None,
9908 )
9909 .await;
9910
9911 let methods = response["result"]["methods"]
9912 .as_array()
9913 .expect("capabilities methods");
9914 assert!(
9915 methods.iter().all(|method| method != "mobkit/console/send"),
9916 "read-only capabilities must omit send: {methods:#?}"
9917 );
9918 assert_eq!(response["result"]["read_only"], json!(true));
9919 assert_eq!(
9920 response["result"]["runtime_capabilities"]["can_send_messages"],
9921 json!(false)
9922 );
9923 }
9924
9925 #[tokio::test]
9926 async fn read_only_aggregator_denies_direct_mutating_rpc() {
9927 let response = handle_console_aggregator_rpc(
9928 None,
9929 rpc_request_with_params(
9930 "mobkit/console/send",
9931 json!({
9932 "identity": "worker",
9933 "content": "hello",
9934 "origin": "test",
9935 "idempotency_key": "read-only-send",
9936 }),
9937 ),
9938 true,
9939 true,
9940 None,
9941 None,
9942 )
9943 .await;
9944
9945 assert_eq!(response["result"], Value::Null);
9946 assert_eq!(response["error"]["code"], json!(-32010));
9947 assert_eq!(response["error"]["data"]["kind"], json!("read_only"));
9948 }
9949
9950 #[test]
9951 fn read_only_mutating_methods_include_state_draining_collect_completed() {
9952 assert!(super::is_console_mutating_rpc_method(
9953 "mobkit/collect_completed"
9954 ));
9955 }
9956
9957 #[test]
9958 fn authenticated_gating_approver_comes_from_console_principal() {
9959 let forged_params = json!({ "approver_id": "forged-browser-value" });
9960
9961 assert_eq!(
9962 super::resolve_gating_approver_id(&forged_params, Some("admin@example.com"),),
9963 Ok("admin@example.com".to_string()),
9964 );
9965 }
9966
9967 #[test]
9968 fn unauthenticated_local_gating_approver_requires_request_param() {
9969 assert_eq!(
9970 super::resolve_gating_approver_id(&json!({ "approver_id": " local-operator " }), None,),
9971 Ok("local-operator".to_string()),
9972 );
9973 assert_eq!(
9974 super::resolve_gating_approver_id(&json!({}), None),
9975 Err("approver_id required"),
9976 );
9977 }
9978
9979 #[test]
9989 fn internal_error_carries_detail_and_stable_kind() {
9990 let response = super::internal_error(json!(7), "retire failed: backend says no");
9991
9992 assert_eq!(response["error"]["code"], json!(-32000));
9993 assert_eq!(
9994 response["error"]["message"],
9995 json!("retire failed: backend says no")
9996 );
9997 assert_eq!(response["error"]["data"]["error"], json!("internal_error"));
9998 assert_eq!(
9999 response["error"]["data"]["detail"],
10000 json!("retire failed: backend says no")
10001 );
10002 }
10003
10004 #[test]
10005 fn console_send_public_message_hides_dispatch_details() {
10006 let message = super::console_send_public_message(
10007 &crate::console_aggregator::ConsoleSendError::Dispatch(
10008 "secret backend DSN".to_string(),
10009 ),
10010 );
10011
10012 assert_eq!(message, "console send failed");
10013 assert!(!message.contains("secret backend DSN"));
10014 }
10015
10016 #[test]
10017 fn console_send_json_rpc_error_hides_backend_details() {
10018 let response = super::console_send_rpc_error(
10019 json!(7),
10020 crate::console_aggregator::ConsoleSendError::Dispatch("secret backend DSN".to_string()),
10021 );
10022
10023 assert_eq!(response["error"]["code"], json!(-32000));
10024 assert_eq!(response["error"]["message"], json!("console send failed"));
10025 assert!(!response.to_string().contains("secret backend DSN"));
10026 }
10027
10028 #[test]
10029 fn console_timeline_replay_error_hides_backend_details() {
10030 let response = super::console_timeline_replay_unavailable_response(
10031 json!(7),
10032 Box::new(std::io::Error::other("secret backend DSN")),
10033 None,
10034 None,
10035 );
10036
10037 assert_eq!(
10038 response["error"]["code"],
10039 json!(crate::rpc::CONSOLE_TIMELINE_REPLAY_UNAVAILABLE_CODE)
10040 );
10041 assert_eq!(
10042 response["error"]["message"],
10043 json!("timeline replay unavailable")
10044 );
10045 assert!(!response.to_string().contains("secret backend DSN"));
10046 }
10047
10048 #[test]
10049 fn gating_decision_error_hides_backend_details() {
10050 let response = super::gating_decision_failed_error(json!(7), "secret backend DSN");
10051
10052 assert_eq!(response["error"]["code"], json!(-32602));
10053 assert_eq!(
10054 response["error"]["message"],
10055 json!("gating decision failed")
10056 );
10057 assert!(!response.to_string().contains("secret backend DSN"));
10058 }
10059
10060 #[tokio::test]
10061 async fn console_runtime_identity_controls_resolve_durable_member_aliases()
10062 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10063 let (_temp_dir, runtime) =
10064 build_empty_console_test_runtime("console-identity-control-alias").await?;
10065 let mut labels = BTreeMap::new();
10066 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
10067 labels.insert("display_name".to_string(), "Review Agent".to_string());
10068 runtime
10069 .handle()
10070 .spawn_spec(
10071 SpawnMemberSpec::from_wire(
10072 "worker".to_string(),
10073 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10076 Some("You are Review Agent.".into()),
10077 None,
10078 None,
10079 )
10080 .with_labels(labels),
10081 )
10082 .await?;
10083
10084 let durable_status = Box::pin(handle_console_runtime_rpc(
10085 &runtime,
10086 None,
10087 None,
10088 None,
10089 None,
10090 None,
10091 None,
10092 None,
10093 None,
10094 rpc_request_with_params(
10095 "mobkit/status_identity",
10096 json!({ "identity": "review:singleton" }),
10097 ),
10098 true,
10099 ))
10100 .await;
10101 assert_eq!(durable_status["error"], Value::Null);
10102 assert_eq!(
10103 durable_status["result"]["identity"],
10104 json!("review:singleton")
10105 );
10106 assert_eq!(
10107 durable_status["result"]["agent_runtime_id"],
10108 json!("rt:review:singleton:0")
10109 );
10110
10111 let runtime_id_status = Box::pin(handle_console_runtime_rpc(
10112 &runtime,
10113 None,
10114 None,
10115 None,
10116 None,
10117 None,
10118 None,
10119 None,
10120 None,
10121 rpc_request_with_params(
10122 "mobkit/status_identity",
10123 json!({ "identity": "rt:review:singleton:0" }),
10124 ),
10125 true,
10126 ))
10127 .await;
10128 assert_eq!(runtime_id_status["error"], Value::Null);
10129 assert_eq!(
10130 runtime_id_status["result"]["identity"],
10131 json!("review:singleton")
10132 );
10133
10134 let runtime_id_inspect = Box::pin(handle_console_runtime_rpc(
10135 &runtime,
10136 None,
10137 None,
10138 None,
10139 None,
10140 None,
10141 None,
10142 None,
10143 None,
10144 rpc_request_with_params(
10145 "mobkit/inspect_identity",
10146 json!({ "identity": "rt:review:singleton:0" }),
10147 ),
10148 true,
10149 ))
10150 .await;
10151 assert_eq!(runtime_id_inspect["error"], Value::Null);
10152 assert_eq!(
10153 runtime_id_inspect["result"]["identity"],
10154 json!("review:singleton")
10155 );
10156
10157 let respawn = Box::pin(handle_console_runtime_rpc(
10158 &runtime,
10159 None,
10160 None,
10161 None,
10162 Some(ConsoleEventStore::new()),
10163 None,
10164 None,
10165 None,
10166 None,
10167 rpc_request_with_params("mobkit/respawn", json!({ "identity": "review:singleton" })),
10168 true,
10169 ))
10170 .await;
10171 assert_eq!(respawn["error"], Value::Null);
10172 assert_eq!(respawn["result"]["identity"], json!("review:singleton"));
10173 assert_eq!(
10174 respawn["result"]["agent_runtime_id"],
10175 json!("rt:review:singleton:0")
10176 );
10177
10178 let reset_without_identity_runtime = Box::pin(handle_console_runtime_rpc(
10182 &runtime,
10183 None,
10184 None,
10185 None,
10186 Some(ConsoleEventStore::new()),
10187 None,
10188 None,
10189 None,
10190 None,
10191 rpc_request_with_params("mobkit/reset", json!({ "identity": "review:singleton" })),
10192 true,
10193 ))
10194 .await;
10195 assert_eq!(
10196 reset_without_identity_runtime["error"],
10197 Value::Null,
10198 "{reset_without_identity_runtime:#?}"
10199 );
10200 assert_eq!(
10201 reset_without_identity_runtime["result"]["identity"],
10202 json!("review:singleton")
10203 );
10204 assert_eq!(
10205 reset_without_identity_runtime["result"]["agent_runtime_id"],
10206 json!("rt:review:singleton:0")
10207 );
10208
10209 let reset_unknown_identity = Box::pin(handle_console_runtime_rpc(
10210 &runtime,
10211 None,
10212 None,
10213 None,
10214 Some(ConsoleEventStore::new()),
10215 None,
10216 None,
10217 None,
10218 None,
10219 rpc_request_with_params("mobkit/reset", json!({ "identity": "missing:member" })),
10220 true,
10221 ))
10222 .await;
10223 assert_ne!(reset_unknown_identity["error"], Value::Null);
10224 assert!(
10225 reset_unknown_identity["error"]["message"]
10226 .as_str()
10227 .unwrap_or_default()
10228 .contains("identity not found")
10229 );
10230
10231 let _ = runtime.handle().stop().await;
10232 Ok(())
10233 }
10234
10235 #[tokio::test]
10236 async fn console_runtime_identity_controls_reject_ambiguous_live_label_aliases()
10237 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10238 let (_temp_dir, runtime) =
10239 build_empty_console_test_runtime("console-identity-ambiguous-live-alias").await?;
10240 for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
10241 let mut labels = BTreeMap::new();
10242 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
10243 runtime
10244 .handle()
10245 .spawn_spec(
10246 SpawnMemberSpec::from_wire(
10247 "worker".to_string(),
10248 crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
10251 Some("You are a duplicate Review Agent.".into()),
10252 None,
10253 None,
10254 )
10255 .with_labels(labels),
10256 )
10257 .await?;
10258 }
10259
10260 for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
10261 for method in [
10262 "mobkit/status_identity",
10263 "mobkit/inspect_identity",
10264 "mobkit/retire",
10265 "mobkit/respawn",
10266 "mobkit/reset",
10267 ] {
10268 let response = Box::pin(handle_console_runtime_rpc(
10269 &runtime,
10270 None,
10271 None,
10272 None,
10273 Some(ConsoleEventStore::new()),
10274 None,
10275 None,
10276 None,
10277 None,
10278 rpc_request_with_params(method, json!({ "identity": requested_identity })),
10279 true,
10280 ))
10281 .await;
10282 assert_ne!(
10283 response["error"],
10284 Value::Null,
10285 "{method} must reject ambiguous live alias for {requested_identity}"
10286 );
10287 assert_eq!(
10288 response["error"]["data"]["kind"],
10289 json!("ambiguous_live_identity_alias"),
10290 "unexpected response for {method}/{requested_identity}: {response:#?}"
10291 );
10292 }
10293 }
10294
10295 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10296 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10297 lease_provider: Arc::new(LocalLeaseProvider::new()),
10298 runtime_instance_id: "console-identity-ambiguous-live-alias".to_string(),
10299 has_runtime_store: true,
10300 durability_policy: DurabilityPolicy::SyncWriteThrough,
10301 bridge: None,
10302 default_timeout: None,
10303 }));
10304 for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
10305 for method in ["mobkit/reset", "mobkit/delete_identity"] {
10306 let response = Box::pin(handle_console_runtime_rpc(
10307 &runtime,
10308 None,
10309 None,
10310 None,
10311 Some(ConsoleEventStore::new()),
10312 None,
10313 Some(identity_runtime.clone()),
10314 None,
10315 None,
10316 rpc_request_with_params(method, json!({ "identity": requested_identity })),
10317 true,
10318 ))
10319 .await;
10320 assert_ne!(
10321 response["error"],
10322 Value::Null,
10323 "{method} must reject ambiguous live alias for {requested_identity}"
10324 );
10325 assert_eq!(
10326 response["error"]["data"]["kind"],
10327 json!("ambiguous_live_identity_alias"),
10328 "unexpected response for {method}/{requested_identity}: {response:#?}"
10329 );
10330 }
10331 }
10332
10333 let _ = runtime.handle().stop().await;
10334 Ok(())
10335 }
10336
10337 #[tokio::test]
10338 async fn console_runtime_durable_identity_prefers_registered_live_over_duplicate_labels()
10339 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10340 let (_temp_dir, runtime) =
10341 build_empty_console_test_runtime("console-durable-wins-duplicate-live-labels").await?;
10342 for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
10343 let mut labels = BTreeMap::new();
10344 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
10345 runtime
10346 .handle()
10347 .spawn_spec(
10348 SpawnMemberSpec::from_wire(
10349 "worker".to_string(),
10350 crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
10353 Some("You are a Review Agent candidate.".into()),
10354 None,
10355 None,
10356 )
10357 .with_labels(labels),
10358 )
10359 .await?;
10360 }
10361
10362 let store = Arc::new(LocalContinuityStore::in_memory()?);
10363 let lease_provider = Arc::new(LocalLeaseProvider::new());
10364 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10365 continuity_store: store.clone(),
10366 lease_provider: lease_provider.clone(),
10367 runtime_instance_id: "test-runtime".to_string(),
10368 has_runtime_store: true,
10369 durability_policy: DurabilityPolicy::SyncWriteThrough,
10370 bridge: None,
10371 default_timeout: None,
10372 }));
10373 let identity = AgentIdentity::parse("review:singleton")?;
10374 let registered_session_id = runtime
10375 .handle()
10376 .resolve_bridge_session_id_observation(&crate::member_comms_id::mob_member_id(
10377 "rt:review:singleton:0",
10378 ))
10379 .await
10380 .unwrap_or_else(meerkat_core::types::SessionId::new);
10381 let record = ContinuityRecord {
10382 identity: identity.clone(),
10383 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
10384 session_id: registered_session_id,
10385 generation: ContinuityGeneration::new(0),
10386 checkpoint_version: CheckpointVersion::new(0),
10387 };
10388 let grants = lease_provider
10389 .acquire_leases(std::slice::from_ref(&identity), "test-runtime")
10390 .await?;
10391 let grant = match grants.get(&identity).cloned() {
10392 Some(LeaseAcquireResult::Acquired(grant)) => grant,
10393 other => return Err(format!("expected acquired lease, got {other:?}").into()),
10394 };
10395 store
10396 .upsert_continuity_record(&record, grant.fencing_token)
10397 .await?;
10398 identity_runtime
10399 .register(
10400 DurableAgentSpec {
10401 identity: identity.clone(),
10402 profile: ProfileName::from("worker"),
10403 addressability: AgentAddressability::Addressable,
10404 display_name: None,
10405 labels: BTreeMap::new(),
10406 context: None,
10407 additional_instructions: Vec::new(),
10408 initial_message: None,
10409 runtime_mode_override: None,
10410 backend: None,
10411 binding: None,
10412 },
10413 IdentityLifecycleState::Active,
10414 Some(record),
10415 Some(grant),
10416 )
10417 .await;
10418
10419 for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
10420 for method in ["mobkit/status_identity", "mobkit/inspect_identity"] {
10421 let response = Box::pin(handle_console_runtime_rpc(
10422 &runtime,
10423 None,
10424 None,
10425 None,
10426 Some(ConsoleEventStore::new()),
10427 None,
10428 Some(identity_runtime.clone()),
10429 None,
10430 None,
10431 rpc_request_with_params(method, json!({ "identity": requested_identity })),
10432 true,
10433 ))
10434 .await;
10435 assert_eq!(
10436 response["error"],
10437 Value::Null,
10438 "{method} must use durable registered live binding despite duplicate labels for {requested_identity}: {response:#?}"
10439 );
10440 }
10441 }
10442 let reset_all_response = Box::pin(handle_console_runtime_rpc(
10443 &runtime,
10444 None,
10445 None,
10446 None,
10447 Some(ConsoleEventStore::new()),
10448 None,
10449 Some(identity_runtime.clone()),
10450 None,
10451 None,
10452 rpc_request("mobkit/reset_all"),
10453 true,
10454 ))
10455 .await;
10456 assert_eq!(
10457 reset_all_response["error"],
10458 Value::Null,
10459 "reset_all must also prefer the durable registered live binding despite duplicate labels: {reset_all_response:#?}"
10460 );
10461 assert!(
10462 reset_all_response["result"]["failed"]
10463 .as_array()
10464 .is_some_and(Vec::is_empty),
10465 "reset_all should not report duplicate-label failure for durable registered binding: {reset_all_response:#?}"
10466 );
10467
10468 let _ = runtime.handle().stop().await;
10469 Ok(())
10470 }
10471
10472 #[tokio::test]
10473 async fn console_runtime_identity_controls_reject_wrong_projected_live_only_alias()
10474 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10475 let (_temp_dir, runtime) =
10476 build_empty_console_test_runtime("console-identity-wrong-projected-live-only").await?;
10477
10478 let mut labels = BTreeMap::new();
10479 labels.insert("agent_identity".to_string(), "other:singleton".to_string());
10480 runtime
10481 .handle()
10482 .spawn_spec(
10483 SpawnMemberSpec::from_wire(
10484 "worker".to_string(),
10485 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10488 Some("You are a wrong-projected Review Agent.".into()),
10489 None,
10490 None,
10491 )
10492 .with_labels(labels),
10493 )
10494 .await?;
10495
10496 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10497 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10498 lease_provider: Arc::new(LocalLeaseProvider::new()),
10499 runtime_instance_id: "console-identity-wrong-projected-live-only".to_string(),
10500 has_runtime_store: true,
10501 durability_policy: DurabilityPolicy::SyncWriteThrough,
10502 bridge: None,
10503 default_timeout: None,
10504 }));
10505 let identity = AgentIdentity::parse("review:singleton")?;
10506 let record = ContinuityRecord {
10507 identity: identity.clone(),
10508 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
10509 session_id: meerkat_core::types::SessionId::new(),
10510 generation: ContinuityGeneration::new(0),
10511 checkpoint_version: CheckpointVersion::new(0),
10512 };
10513 identity_runtime
10514 .register(
10515 DurableAgentSpec {
10516 identity: identity.clone(),
10517 profile: ProfileName::from("worker"),
10518 addressability: AgentAddressability::Addressable,
10519 display_name: None,
10520 labels: BTreeMap::new(),
10521 context: None,
10522 additional_instructions: Vec::new(),
10523 initial_message: None,
10524 runtime_mode_override: None,
10525 backend: None,
10526 binding: None,
10527 },
10528 IdentityLifecycleState::Active,
10529 Some(record),
10530 Some(LeaseGrant {
10531 identity,
10532 fencing_token: FencingToken::new(1),
10533 ttl: Duration::from_mins(1),
10534 }),
10535 )
10536 .await;
10537
10538 for method in [
10539 "mobkit/status_identity",
10540 "mobkit/inspect_identity",
10541 "mobkit/retire",
10542 ] {
10543 let response = Box::pin(handle_console_runtime_rpc(
10544 &runtime,
10545 None,
10546 None,
10547 None,
10548 Some(ConsoleEventStore::new()),
10549 None,
10550 Some(identity_runtime.clone()),
10551 None,
10552 None,
10553 rpc_request_with_params(method, json!({ "identity": "other:singleton" })),
10554 true,
10555 ))
10556 .await;
10557 assert_ne!(
10558 response["error"],
10559 Value::Null,
10560 "{method} must reject wrong-projected live-only alias"
10561 );
10562 assert_eq!(
10563 response["error"]["data"]["kind"],
10564 json!("stale_live_identity_alias"),
10565 "unexpected response for {method}: {response:#?}"
10566 );
10567 }
10568 assert!(
10569 runtime
10570 .handle()
10571 .get_member(&crate::member_comms_id::mob_member_id(
10572 "rt:review:singleton:0"
10573 ))
10574 .await
10575 .ok()
10576 .flatten()
10577 .is_some(),
10578 "wrong-projected durable runtime member must not be retired through projected alias"
10579 );
10580
10581 let _ = runtime.handle().stop().await;
10582 Ok(())
10583 }
10584
10585 #[derive(Debug)]
10586 struct HideIdentityPolicy(&'static str);
10587
10588 impl ConsoleVisibilityPolicy for HideIdentityPolicy {
10589 fn identity_visible(&self, record: &ConsoleIdentityRecord) -> bool {
10590 record.identity != self.0
10591 }
10592 }
10593
10594 #[derive(Debug)]
10595 struct HideMemberPolicy(&'static str);
10596
10597 impl ConsoleVisibilityPolicy for HideMemberPolicy {
10598 fn member_visible(&self, member: &ConsoleMember) -> bool {
10599 member.agent_identity != self.0
10600 && member
10601 .labels
10602 .get("agent_identity")
10603 .is_none_or(|identity| identity != self.0)
10604 }
10605
10606 fn identity_visible(&self, record: &ConsoleIdentityRecord) -> bool {
10607 record.runtime_member_id != self.0
10608 }
10609 }
10610
10611 #[derive(Debug)]
10612 struct HideOnlyMemberPolicy(&'static str);
10613
10614 impl ConsoleVisibilityPolicy for HideOnlyMemberPolicy {
10615 fn member_visible(&self, member: &ConsoleMember) -> bool {
10616 member.agent_identity != self.0
10617 }
10618 }
10619
10620 #[tokio::test]
10621 async fn console_runtime_identity_controls_respect_visibility_policy()
10622 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10623 let (_temp_dir, runtime) =
10624 build_empty_console_test_runtime("console-identity-hidden-controls").await?;
10625 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10626 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10627 lease_provider: Arc::new(LocalLeaseProvider::new()),
10628 runtime_instance_id: "console-identity-hidden-controls".to_string(),
10629 has_runtime_store: true,
10630 durability_policy: DurabilityPolicy::SyncWriteThrough,
10631 bridge: None,
10632 default_timeout: None,
10633 }));
10634 let identity = AgentIdentity::parse("review:singleton")?;
10635 let record = ContinuityRecord {
10636 identity: identity.clone(),
10637 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
10638 session_id: meerkat_core::types::SessionId::new(),
10639 generation: ContinuityGeneration::new(0),
10640 checkpoint_version: CheckpointVersion::new(0),
10641 };
10642 identity_runtime
10643 .register(
10644 DurableAgentSpec {
10645 identity: identity.clone(),
10646 profile: ProfileName::from("worker"),
10647 addressability: AgentAddressability::Addressable,
10648 display_name: None,
10649 labels: BTreeMap::new(),
10650 context: None,
10651 additional_instructions: Vec::new(),
10652 initial_message: None,
10653 runtime_mode_override: None,
10654 backend: None,
10655 binding: None,
10656 },
10657 IdentityLifecycleState::Active,
10658 Some(record),
10659 Some(LeaseGrant {
10660 identity: identity.clone(),
10661 fencing_token: FencingToken::new(7),
10662 ttl: Duration::from_mins(1),
10663 }),
10664 )
10665 .await;
10666
10667 for method in [
10668 "mobkit/status_identity",
10669 "mobkit/inspect_identity",
10670 "mobkit/retire",
10671 "mobkit/respawn",
10672 "mobkit/reset",
10673 "mobkit/delete_identity",
10674 ] {
10675 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
10676 &runtime,
10677 None,
10678 None,
10679 None,
10680 Some(ConsoleEventStore::new()),
10681 None,
10682 Some(identity_runtime.clone()),
10683 None,
10684 None,
10685 &HideIdentityPolicy("review:singleton"),
10686 rpc_request_with_params(method, json!({ "identity": "review:singleton" })),
10687 true,
10688 false,
10689 None,
10690 None,
10691 None,
10692 None,
10693 None,
10694 None,
10695 None,
10696 ))
10697 .await;
10698 assert_ne!(
10699 response["error"],
10700 Value::Null,
10701 "{method} must reject hidden durable identity"
10702 );
10703 assert_eq!(
10704 response["error"]["data"]["kind"],
10705 json!("identity_hidden_by_policy"),
10706 "unexpected hidden response for {method}: {response:#?}"
10707 );
10708 }
10709 identity_runtime
10710 .status(&AgentIdentity::parse("review:singleton")?)
10711 .await
10712 .expect("hidden control RPCs must not mutate the durable identity");
10713
10714 let _ = runtime.handle().stop().await;
10715 Ok(())
10716 }
10717
10718 #[tokio::test]
10719 async fn console_runtime_durable_identity_controls_reject_hidden_bound_member()
10720 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10721 let (_temp_dir, runtime) =
10722 build_empty_console_test_runtime("console-durable-hidden-bound-member").await?;
10723 runtime
10724 .handle()
10725 .spawn_spec(SpawnMemberSpec::from_wire(
10726 "worker".to_string(),
10727 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10730 Some("You are the live Review Agent.".into()),
10731 None,
10732 None,
10733 ))
10734 .await?;
10735
10736 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10737 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10738 lease_provider: Arc::new(LocalLeaseProvider::new()),
10739 runtime_instance_id: "console-durable-hidden-bound-member".to_string(),
10740 has_runtime_store: true,
10741 durability_policy: DurabilityPolicy::SyncWriteThrough,
10742 bridge: None,
10743 default_timeout: None,
10744 }));
10745 let identity = AgentIdentity::parse("review:singleton")?;
10746 let record = ContinuityRecord {
10747 identity: identity.clone(),
10748 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
10749 session_id: meerkat_core::types::SessionId::new(),
10750 generation: ContinuityGeneration::new(0),
10751 checkpoint_version: CheckpointVersion::new(0),
10752 };
10753 identity_runtime
10754 .register(
10755 DurableAgentSpec {
10756 identity: identity.clone(),
10757 profile: ProfileName::from("worker"),
10758 addressability: AgentAddressability::Addressable,
10759 display_name: None,
10760 labels: BTreeMap::new(),
10761 context: None,
10762 additional_instructions: Vec::new(),
10763 initial_message: None,
10764 runtime_mode_override: None,
10765 backend: None,
10766 binding: None,
10767 },
10768 IdentityLifecycleState::Active,
10769 Some(record),
10770 Some(LeaseGrant {
10771 identity: identity.clone(),
10772 fencing_token: FencingToken::new(7),
10773 ttl: Duration::from_mins(1),
10774 }),
10775 )
10776 .await;
10777
10778 for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
10779 for method in [
10780 "mobkit/status_identity",
10781 "mobkit/inspect_identity",
10782 "mobkit/retire",
10783 "mobkit/respawn",
10784 "mobkit/reset",
10785 "mobkit/delete_identity",
10786 ] {
10787 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
10788 &runtime,
10789 None,
10790 None,
10791 None,
10792 Some(ConsoleEventStore::new()),
10793 None,
10794 Some(identity_runtime.clone()),
10795 None,
10796 None,
10797 &HideOnlyMemberPolicy("rt:review:singleton:0"),
10798 rpc_request_with_params(method, json!({ "identity": requested_identity })),
10799 true,
10800 false,
10801 None,
10802 None,
10803 None,
10804 None,
10805 None,
10806 None,
10807 None,
10808 ))
10809 .await;
10810 assert_eq!(
10811 response["error"]["data"]["kind"],
10812 json!("identity_hidden_by_policy"),
10813 "durable {method} must reject hidden bound live member for {requested_identity}: {response:#?}"
10814 );
10815 }
10816 }
10817
10818 let _ = runtime.handle().stop().await;
10819 Ok(())
10820 }
10821
10822 #[tokio::test]
10823 async fn console_runtime_live_only_identity_controls_respect_visibility_policy()
10824 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10825 let (_temp_dir, runtime) =
10826 build_empty_console_test_runtime("console-live-only-hidden-controls").await?;
10827
10828 let mut labels = BTreeMap::new();
10829 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
10830 runtime
10831 .handle()
10832 .spawn_spec(
10833 SpawnMemberSpec::from_wire(
10834 "worker".to_string(),
10835 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10838 Some("You are the live Review Agent.".into()),
10839 None,
10840 None,
10841 )
10842 .with_labels(labels),
10843 )
10844 .await?;
10845
10846 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10847 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10848 lease_provider: Arc::new(LocalLeaseProvider::new()),
10849 runtime_instance_id: "console-live-only-hidden-controls".to_string(),
10850 has_runtime_store: true,
10851 durability_policy: DurabilityPolicy::SyncWriteThrough,
10852 bridge: None,
10853 default_timeout: None,
10854 }));
10855
10856 for method in [
10857 "mobkit/status_identity",
10858 "mobkit/inspect_identity",
10859 "mobkit/retire",
10860 "mobkit/respawn",
10861 "mobkit/reset",
10862 "mobkit/delete_identity",
10863 ] {
10864 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
10865 &runtime,
10866 None,
10867 None,
10868 None,
10869 Some(ConsoleEventStore::new()),
10870 None,
10871 Some(identity_runtime.clone()),
10872 None,
10873 None,
10874 &HideMemberPolicy("rt:review:singleton:0"),
10875 rpc_request_with_params(method, json!({ "identity": "review:singleton" })),
10876 true,
10877 false,
10878 None,
10879 None,
10880 None,
10881 None,
10882 None,
10883 None,
10884 None,
10885 ))
10886 .await;
10887 assert_ne!(
10888 response["error"],
10889 Value::Null,
10890 "{method} must reject hidden live-only identity"
10891 );
10892 assert_eq!(
10893 response["error"]["data"]["kind"],
10894 json!("identity_hidden_by_policy"),
10895 "unexpected hidden live-only response for {method}: {response:#?}"
10896 );
10897 }
10898 assert!(
10899 runtime
10900 .handle()
10901 .get_member(&crate::member_comms_id::mob_member_id(
10902 "rt:review:singleton:0"
10903 ))
10904 .await
10905 .ok()
10906 .flatten()
10907 .is_some(),
10908 "hidden live-only controls must not mutate the live member"
10909 );
10910
10911 let _ = runtime.handle().stop().await;
10912 Ok(())
10913 }
10914
10915 #[tokio::test]
10916 async fn console_runtime_reset_live_only_alias_without_session_bridge_uses_live_fallback()
10917 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10918 let (_temp_dir, runtime) =
10919 build_empty_console_test_runtime("console-reset-live-only-no-bridge").await?;
10920
10921 let mut labels = BTreeMap::new();
10922 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
10923 runtime
10924 .handle()
10925 .spawn_spec(
10926 SpawnMemberSpec::from_wire(
10927 "worker".to_string(),
10928 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10931 Some("You are the live Review Agent.".into()),
10932 None,
10933 None,
10934 )
10935 .with_labels(labels),
10936 )
10937 .await?;
10938
10939 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10940 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10941 lease_provider: Arc::new(LocalLeaseProvider::new()),
10942 runtime_instance_id: "console-reset-live-only-no-bridge".to_string(),
10943 has_runtime_store: true,
10944 durability_policy: DurabilityPolicy::SyncWriteThrough,
10945 bridge: None,
10946 default_timeout: None,
10947 }));
10948
10949 let response = Box::pin(handle_console_runtime_rpc(
10950 &runtime,
10951 None,
10952 None,
10953 None,
10954 Some(ConsoleEventStore::new()),
10955 None,
10956 Some(identity_runtime),
10957 None,
10958 None,
10959 rpc_request_with_params("mobkit/reset", json!({ "identity": "review:singleton" })),
10960 true,
10961 ))
10962 .await;
10963 assert_eq!(
10964 response["error"],
10965 Value::Null,
10966 "live-only reset should use live fallback instead of requiring session bridge: {response:#?}"
10967 );
10968 assert_eq!(response["result"]["identity"], json!("review:singleton"));
10969
10970 let _ = runtime.handle().stop().await;
10971 Ok(())
10972 }
10973
10974 #[tokio::test]
10975 async fn reset_all_rejects_registered_runtime_projected_under_wrong_identity()
10976 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10977 let (_temp_dir, runtime) =
10978 build_empty_console_test_runtime("console-reset-all-stale-projection").await?;
10979
10980 let mut labels = BTreeMap::new();
10981 labels.insert("agent_identity".to_string(), "other:singleton".to_string());
10982 runtime
10983 .handle()
10984 .spawn_spec(
10985 SpawnMemberSpec::from_wire(
10986 "worker".to_string(),
10987 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10990 Some("You are a mislabeled Review Agent.".into()),
10991 None,
10992 None,
10993 )
10994 .with_labels(labels),
10995 )
10996 .await?;
10997
10998 let store = Arc::new(LocalContinuityStore::in_memory()?);
10999 let lease_provider = Arc::new(LocalLeaseProvider::new());
11000 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11001 continuity_store: store.clone(),
11002 lease_provider: lease_provider.clone(),
11003 runtime_instance_id: "test-runtime".to_string(),
11004 has_runtime_store: true,
11005 durability_policy: DurabilityPolicy::SyncWriteThrough,
11006 bridge: None,
11007 default_timeout: None,
11008 }));
11009 let identity = AgentIdentity::parse("review:singleton")?;
11010 let record = ContinuityRecord {
11011 identity: identity.clone(),
11012 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11013 session_id: meerkat_core::types::SessionId::new(),
11014 generation: ContinuityGeneration::new(0),
11015 checkpoint_version: CheckpointVersion::new(0),
11016 };
11017 let grants = lease_provider
11018 .acquire_leases(std::slice::from_ref(&identity), "test-runtime")
11019 .await?;
11020 let grant = match grants.get(&identity).cloned() {
11021 Some(LeaseAcquireResult::Acquired(grant)) => grant,
11022 other => return Err(format!("expected acquired lease, got {other:?}").into()),
11023 };
11024 store
11025 .upsert_continuity_record(&record, grant.fencing_token)
11026 .await?;
11027 identity_runtime
11028 .register(
11029 DurableAgentSpec {
11030 identity: identity.clone(),
11031 profile: ProfileName::from("worker"),
11032 addressability: AgentAddressability::Addressable,
11033 display_name: None,
11034 labels: BTreeMap::new(),
11035 context: None,
11036 additional_instructions: Vec::new(),
11037 initial_message: None,
11038 runtime_mode_override: None,
11039 backend: None,
11040 binding: None,
11041 },
11042 IdentityLifecycleState::Active,
11043 Some(record),
11044 Some(grant),
11045 )
11046 .await;
11047
11048 let response = Box::pin(handle_console_runtime_rpc(
11049 &runtime,
11050 None,
11051 None,
11052 None,
11053 Some(ConsoleEventStore::new()),
11054 None,
11055 Some(identity_runtime),
11056 None,
11057 None,
11058 rpc_request("mobkit/reset_all"),
11059 true,
11060 ))
11061 .await;
11062 assert_ne!(response["error"], Value::Null);
11063 let failed = response["error"]["data"]["failed"]
11064 .as_array()
11065 .expect("reset_all should report failed identities");
11066 let stale_failure = failed
11067 .iter()
11068 .find(|failure| failure["identity"] == json!("review:singleton"))
11069 .expect("review identity should fail stale alias validation");
11070 assert_eq!(
11071 stale_failure["kind"],
11072 json!("stale_live_identity_alias"),
11073 "unexpected reset_all response: {response:#?}"
11074 );
11075 assert!(
11076 stale_failure["error"]
11077 .as_str()
11078 .unwrap_or_default()
11079 .contains("projects identity other:singleton"),
11080 "unexpected stale failure: {stale_failure:#?}"
11081 );
11082 let retired = response["error"]["data"]["retired_delegates"]
11083 .as_array()
11084 .expect("reset_all should return retired delegates");
11085 assert!(
11086 !retired
11087 .iter()
11088 .any(|entry| entry["identity"] == json!("other:singleton")),
11089 "wrong-projected live alias must not be destructively retired before stale validation; response: {response:#?}"
11090 );
11091 assert!(
11092 runtime
11093 .handle()
11094 .get_member(&crate::member_comms_id::mob_member_id(
11095 "rt:review:singleton:0"
11096 ))
11097 .await
11098 .ok()
11099 .flatten()
11100 .is_some(),
11101 "wrong-projected durable runtime member must remain present after reset_all rejection"
11102 );
11103
11104 let _ = runtime.handle().stop().await;
11105 Ok(())
11106 }
11107
11108 #[tokio::test]
11109 async fn reset_all_respects_console_visibility_policy_for_live_members()
11110 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11111 let (_temp_dir, runtime) =
11112 build_empty_console_test_runtime("console-reset-all-hidden-live").await?;
11113
11114 runtime
11115 .handle()
11116 .spawn_spec(
11117 SpawnMemberSpec::from_wire(
11118 "worker".to_string(),
11119 crate::member_comms_id::mob_member_id_str("hidden:singleton").into_owned(),
11122 Some("You are hidden from console lifecycle controls.".into()),
11123 None,
11124 None,
11125 )
11126 .with_labels(BTreeMap::from([(
11127 "agent_identity".to_string(),
11128 "hidden:singleton".to_string(),
11129 )])),
11130 )
11131 .await?;
11132
11133 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
11134 &runtime,
11135 None,
11136 None,
11137 None,
11138 Some(ConsoleEventStore::new()),
11139 None,
11140 None,
11141 None,
11142 None,
11143 &HideMemberPolicy("hidden:singleton"),
11144 rpc_request("mobkit/reset_all"),
11145 true,
11146 false,
11147 None,
11148 None,
11149 None,
11150 None,
11151 None,
11152 None,
11153 None,
11154 ))
11155 .await;
11156 assert_eq!(
11157 response["error"],
11158 Value::Null,
11159 "hidden live member should be outside reset_all target set: {response:#?}"
11160 );
11161 assert!(
11162 runtime
11163 .handle()
11164 .get_member(&crate::member_comms_id::mob_member_id("hidden:singleton"))
11165 .await
11166 .ok()
11167 .flatten()
11168 .is_some(),
11169 "reset_all must not retire hidden live members"
11170 );
11171
11172 let _ = runtime.handle().stop().await;
11173 Ok(())
11174 }
11175
11176 #[tokio::test]
11177 async fn reset_all_skips_durable_identity_with_hidden_bound_member()
11178 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11179 let (_temp_dir, runtime) =
11180 build_empty_console_test_runtime("console-reset-all-hidden-durable-bound").await?;
11181 runtime
11182 .handle()
11183 .spawn_spec(SpawnMemberSpec::from_wire(
11184 "worker".to_string(),
11185 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
11188 Some("You are the hidden Review Agent.".into()),
11189 None,
11190 None,
11191 ))
11192 .await?;
11193
11194 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11195 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11196 lease_provider: Arc::new(LocalLeaseProvider::new()),
11197 runtime_instance_id: "console-reset-all-hidden-durable-bound".to_string(),
11198 has_runtime_store: true,
11199 durability_policy: DurabilityPolicy::SyncWriteThrough,
11200 bridge: None,
11201 default_timeout: None,
11202 }));
11203 let identity = AgentIdentity::parse("review:singleton")?;
11204 identity_runtime
11205 .register(
11206 DurableAgentSpec {
11207 identity: identity.clone(),
11208 profile: ProfileName::from("worker"),
11209 addressability: AgentAddressability::Addressable,
11210 display_name: None,
11211 labels: BTreeMap::new(),
11212 context: None,
11213 additional_instructions: Vec::new(),
11214 initial_message: None,
11215 runtime_mode_override: None,
11216 backend: None,
11217 binding: None,
11218 },
11219 IdentityLifecycleState::Active,
11220 Some(ContinuityRecord {
11221 identity: identity.clone(),
11222 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11223 session_id: meerkat_core::types::SessionId::new(),
11224 generation: ContinuityGeneration::new(0),
11225 checkpoint_version: CheckpointVersion::new(0),
11226 }),
11227 Some(LeaseGrant {
11228 identity: identity.clone(),
11229 fencing_token: FencingToken::new(9),
11230 ttl: Duration::from_mins(1),
11231 }),
11232 )
11233 .await;
11234
11235 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
11236 &runtime,
11237 None,
11238 None,
11239 None,
11240 Some(ConsoleEventStore::new()),
11241 None,
11242 Some(identity_runtime.clone()),
11243 None,
11244 None,
11245 &HideOnlyMemberPolicy("rt:review:singleton:0"),
11246 rpc_request("mobkit/reset_all"),
11247 true,
11248 false,
11249 None,
11250 None,
11251 None,
11252 None,
11253 None,
11254 None,
11255 None,
11256 ))
11257 .await;
11258 assert_eq!(
11259 response["error"],
11260 Value::Null,
11261 "hidden durable bound member should be outside reset_all target set: {response:#?}"
11262 );
11263 assert_eq!(
11264 identity_runtime.status(&identity).await?.state,
11265 IdentityLifecycleState::Active
11266 );
11267
11268 let _ = runtime.handle().stop().await;
11269 Ok(())
11270 }
11271
11272 #[tokio::test]
11273 async fn identity_lifecycle_cleanup_skips_hidden_projected_duplicates()
11274 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11275 let (_temp_dir, runtime) =
11276 build_empty_console_test_runtime("console-hidden-stale-duplicate-cleanup").await?;
11277 for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
11278 runtime
11279 .handle()
11280 .spawn_spec(
11281 SpawnMemberSpec::from_wire(
11282 "worker".to_string(),
11283 crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
11286 Some("You are a Review Agent candidate.".into()),
11287 None,
11288 None,
11289 )
11290 .with_labels(BTreeMap::from([(
11291 "agent_identity".to_string(),
11292 "review:singleton".to_string(),
11293 )])),
11294 )
11295 .await?;
11296 }
11297
11298 let store = Arc::new(LocalContinuityStore::in_memory()?);
11299 let lease_provider = Arc::new(LocalLeaseProvider::new());
11300 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11301 continuity_store: store.clone(),
11302 lease_provider: lease_provider.clone(),
11303 runtime_instance_id: "console-hidden-stale-duplicate-cleanup".to_string(),
11304 has_runtime_store: true,
11305 durability_policy: DurabilityPolicy::SyncWriteThrough,
11306 bridge: None,
11307 default_timeout: None,
11308 }));
11309 let identity = AgentIdentity::parse("review:singleton")?;
11310 let record = ContinuityRecord {
11311 identity: identity.clone(),
11312 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11313 session_id: runtime
11314 .handle()
11315 .resolve_bridge_session_id_observation(&crate::member_comms_id::mob_member_id(
11316 "rt:review:singleton:0",
11317 ))
11318 .await
11319 .unwrap_or_else(meerkat_core::types::SessionId::new),
11320 generation: ContinuityGeneration::new(0),
11321 checkpoint_version: CheckpointVersion::new(0),
11322 };
11323 let grants = lease_provider
11324 .acquire_leases(
11325 std::slice::from_ref(&identity),
11326 "console-hidden-stale-duplicate-cleanup",
11327 )
11328 .await?;
11329 let grant = match grants.get(&identity).cloned() {
11330 Some(LeaseAcquireResult::Acquired(grant)) => grant,
11331 other => return Err(format!("expected acquired lease, got {other:?}").into()),
11332 };
11333 store
11334 .upsert_continuity_record(&record, grant.fencing_token)
11335 .await?;
11336 identity_runtime
11337 .register(
11338 DurableAgentSpec {
11339 identity: identity.clone(),
11340 profile: ProfileName::from("worker"),
11341 addressability: AgentAddressability::Addressable,
11342 display_name: None,
11343 labels: BTreeMap::new(),
11344 context: None,
11345 additional_instructions: Vec::new(),
11346 initial_message: None,
11347 runtime_mode_override: None,
11348 backend: None,
11349 binding: None,
11350 },
11351 IdentityLifecycleState::Active,
11352 Some(record),
11353 Some(grant),
11354 )
11355 .await;
11356
11357 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
11358 &runtime,
11359 None,
11360 None,
11361 None,
11362 Some(ConsoleEventStore::new()),
11363 None,
11364 Some(identity_runtime),
11365 None,
11366 None,
11367 &HideOnlyMemberPolicy("rt:review:singleton:1"),
11368 rpc_request_with_params("mobkit/retire", json!({ "identity": "review:singleton" })),
11369 true,
11370 false,
11371 None,
11372 None,
11373 None,
11374 None,
11375 None,
11376 None,
11377 None,
11378 ))
11379 .await;
11380 assert_eq!(
11381 response["error"],
11382 Value::Null,
11383 "visible durable retire should succeed without touching hidden duplicate: {response:#?}"
11384 );
11385 assert!(
11386 runtime
11387 .handle()
11388 .get_member(&crate::member_comms_id::mob_member_id(
11389 "rt:review:singleton:1"
11390 ))
11391 .await
11392 .ok()
11393 .flatten()
11394 .is_some(),
11395 "post-mutation stale cleanup must not retire member-hidden projected duplicates"
11396 );
11397
11398 let _ = runtime.handle().stop().await;
11399 Ok(())
11400 }
11401
11402 #[tokio::test]
11403 async fn console_runtime_capabilities_advertise_identity_controls_when_identity_runtime_exists()
11404 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11405 let (_temp_dir, runtime) =
11406 build_empty_console_test_runtime("console-identity-capabilities").await?;
11407 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11408 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11409 lease_provider: Arc::new(LocalLeaseProvider::new()),
11410 runtime_instance_id: "console-identity-capabilities".to_string(),
11411 has_runtime_store: true,
11412 durability_policy: DurabilityPolicy::SyncWriteThrough,
11413 bridge: None,
11414 default_timeout: None,
11415 }));
11416
11417 let response = Box::pin(handle_console_runtime_rpc(
11418 &runtime,
11419 None,
11420 None,
11421 None,
11422 None,
11423 None,
11424 Some(identity_runtime.clone()),
11425 None,
11426 None,
11427 rpc_request("mobkit/capabilities"),
11428 true,
11429 ))
11430 .await;
11431
11432 assert_eq!(response["error"], Value::Null, "{response:#?}");
11433 let methods = response["result"]["methods"]
11434 .as_array()
11435 .ok_or("capabilities methods should be an array")?;
11436 for method in [
11437 "mobkit/status_identity",
11438 "mobkit/inspect_identity",
11439 "mobkit/respawn",
11440 "mobkit/reset",
11441 "mobkit/delete_identity",
11442 ] {
11443 assert!(
11444 methods.iter().any(|candidate| candidate == method),
11445 "identity runtime capabilities should advertise {method}: {methods:#?}"
11446 );
11447 }
11448 for method in crate::rpc::MOBPACK_AUTHORING_METHODS {
11449 assert!(
11450 !methods.iter().any(|candidate| candidate == method),
11451 "console runtime capabilities must not advertise flow-editor method {method}: {methods:#?}"
11452 );
11453 }
11454
11455 let mobpack_response = Box::pin(handle_console_runtime_rpc(
11456 &runtime,
11457 None,
11458 None,
11459 None,
11460 None,
11461 None,
11462 Some(identity_runtime),
11463 None,
11464 None,
11465 rpc_request("mobkit/mobpacks/schema"),
11466 true,
11467 ))
11468 .await;
11469 assert_eq!(
11470 mobpack_response["error"]["code"],
11471 json!(-32601),
11472 "console runtime RPC must not handle flow-editor authoring methods: {mobpack_response:#?}"
11473 );
11474
11475 let _ = runtime.handle().stop().await;
11476 Ok(())
11477 }
11478
11479 #[tokio::test]
11480 async fn console_runtime_capabilities_advertise_agent_memory_with_read_only_gating()
11481 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11482 let (_temp_dir, runtime) =
11483 build_empty_console_test_runtime("console-agent-memory-capabilities").await?;
11484 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11485 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11486 lease_provider: Arc::new(LocalLeaseProvider::new()),
11487 runtime_instance_id: "console-agent-memory-capabilities".to_string(),
11488 has_runtime_store: true,
11489 durability_policy: DurabilityPolicy::SyncWriteThrough,
11490 bridge: None,
11491 default_timeout: None,
11492 }));
11493 let memory_dir = tempfile::tempdir()?;
11494 let memory_store = Arc::new(MarkdownAgentMemoryStore::open(memory_dir.path())?);
11495 identity_runtime
11496 .set_agent_memory(Some(AgentMemoryRuntimeInjector::new(
11497 memory_store,
11498 AgentMemoryConfig::default(),
11499 )))
11500 .await;
11501
11502 let response = Box::pin(handle_console_runtime_rpc(
11503 &runtime,
11504 None,
11505 None,
11506 None,
11507 None,
11508 None,
11509 Some(identity_runtime.clone()),
11510 None,
11511 None,
11512 rpc_request("mobkit/capabilities"),
11513 true,
11514 ))
11515 .await;
11516 assert_eq!(response["error"], Value::Null, "{response:#?}");
11517 let methods = response["result"]["methods"]
11518 .as_array()
11519 .ok_or("capabilities methods should be an array")?;
11520 for method in [
11521 "mobkit/agent_memory/recall",
11522 "mobkit/agent_memory/remember",
11523 "mobkit/agent_memory/forget",
11524 ] {
11525 assert!(
11526 methods.iter().any(|candidate| candidate == method),
11527 "authenticated capabilities should advertise {method}: {methods:#?}"
11528 );
11529 }
11530
11531 let read_only_response = Box::pin(handle_console_runtime_rpc(
11532 &runtime,
11533 None,
11534 None,
11535 None,
11536 None,
11537 None,
11538 Some(identity_runtime),
11539 None,
11540 None,
11541 rpc_request("mobkit/capabilities"),
11542 false,
11543 ))
11544 .await;
11545 assert_eq!(
11546 read_only_response["error"],
11547 Value::Null,
11548 "{read_only_response:#?}"
11549 );
11550 let read_only_methods = read_only_response["result"]["methods"]
11551 .as_array()
11552 .ok_or("read-only capabilities methods should be an array")?;
11553 assert!(
11554 read_only_methods
11555 .iter()
11556 .any(|candidate| candidate == "mobkit/agent_memory/recall"),
11557 "read-only capabilities should keep recall: {read_only_methods:#?}"
11558 );
11559 for method in ["mobkit/agent_memory/remember", "mobkit/agent_memory/forget"] {
11560 assert!(
11561 !read_only_methods
11562 .iter()
11563 .any(|candidate| candidate == method),
11564 "read-only capabilities must omit {method}: {read_only_methods:#?}"
11565 );
11566 }
11567
11568 let _ = runtime.handle().stop().await;
11569 Ok(())
11570 }
11571
11572 #[tokio::test]
11573 async fn console_runtime_agent_memory_methods_round_trip()
11574 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11575 let (_temp_dir, runtime) =
11576 build_empty_console_test_runtime("console-agent-memory-round-trip").await?;
11577 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11578 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11579 lease_provider: Arc::new(LocalLeaseProvider::new()),
11580 runtime_instance_id: "console-agent-memory-round-trip".to_string(),
11581 has_runtime_store: true,
11582 durability_policy: DurabilityPolicy::SyncWriteThrough,
11583 bridge: None,
11584 default_timeout: None,
11585 }));
11586 let identity = AgentIdentity::parse("identity:memory-console")?;
11587 identity_runtime
11588 .register(
11589 DurableAgentSpec {
11590 identity: identity.clone(),
11591 profile: ProfileName::from("default"),
11592 addressability: AgentAddressability::Addressable,
11593 display_name: None,
11594 labels: BTreeMap::new(),
11595 context: None,
11596 additional_instructions: Vec::new(),
11597 initial_message: None,
11598 runtime_mode_override: None,
11599 backend: None,
11600 binding: None,
11601 },
11602 IdentityLifecycleState::Active,
11603 None,
11604 None,
11605 )
11606 .await;
11607 let memory_dir = tempfile::tempdir()?;
11608 let memory_store = Arc::new(MarkdownAgentMemoryStore::open(memory_dir.path())?);
11609 identity_runtime
11610 .set_agent_memory(Some(AgentMemoryRuntimeInjector::new(
11611 memory_store,
11612 AgentMemoryConfig::default(),
11613 )))
11614 .await;
11615
11616 let remember = Box::pin(handle_console_runtime_rpc(
11617 &runtime,
11618 None,
11619 None,
11620 None,
11621 None,
11622 None,
11623 Some(identity_runtime.clone()),
11624 None,
11625 None,
11626 rpc_request_with_params(
11627 "mobkit/agent_memory/remember",
11628 json!({
11629 "identity": "identity:memory-console",
11630 "title": "Console memory token",
11631 "body": "The console memory token is CONSOLE-MEM-17.",
11632 "tags": ["console", "memory"]
11633 }),
11634 ),
11635 true,
11636 ))
11637 .await;
11638 assert_eq!(remember["error"], Value::Null, "{remember:#?}");
11639 let memory_id = remember["result"]["memory_id"]
11640 .as_str()
11641 .ok_or("remember result should include memory_id")?
11642 .to_string();
11643
11644 let recall = Box::pin(handle_console_runtime_rpc(
11645 &runtime,
11646 None,
11647 None,
11648 None,
11649 None,
11650 None,
11651 Some(identity_runtime.clone()),
11652 None,
11653 None,
11654 rpc_request_with_params(
11655 "mobkit/agent_memory/recall",
11656 json!({
11657 "identity": "identity:memory-console",
11658 "selection": "contextual",
11659 "query_text": "Where is CONSOLE-MEM-17?",
11660 "query_terms": ["CONSOLE-MEM-17"]
11661 }),
11662 ),
11663 false,
11664 ))
11665 .await;
11666 assert_eq!(recall["error"], Value::Null, "{recall:#?}");
11667 assert_eq!(
11668 recall["result"]["records"]
11669 .as_array()
11670 .map(std::vec::Vec::len),
11671 Some(1)
11672 );
11673 assert_eq!(
11674 recall["result"]["records"][0]["memory_id"],
11675 json!(memory_id)
11676 );
11677
11678 let read_only_remember = Box::pin(handle_console_runtime_rpc(
11679 &runtime,
11680 None,
11681 None,
11682 None,
11683 None,
11684 None,
11685 Some(identity_runtime.clone()),
11686 None,
11687 None,
11688 rpc_request_with_params(
11689 "mobkit/agent_memory/remember",
11690 json!({
11691 "identity": "identity:memory-console",
11692 "title": "Denied",
11693 "body": "Denied"
11694 }),
11695 ),
11696 false,
11697 ))
11698 .await;
11699 assert_eq!(
11700 read_only_remember["error"]["data"]["kind"],
11701 json!("read_only"),
11702 "{read_only_remember:#?}"
11703 );
11704
11705 let access_controller = AccessController::new(crate::access::AccessControlConfig {
11706 enabled: true,
11707 admins: vec!["admin@example.test".to_string()],
11708 ..crate::access::AccessControlConfig::default()
11709 })?;
11710 let denied_view = access_controller.view_for_subject(Some("viewer@example.test"));
11711 let denied_recall = Box::pin(handle_console_runtime_rpc_with_visibility(
11712 &runtime,
11713 None,
11714 None,
11715 None,
11716 None,
11717 None,
11718 Some(identity_runtime.clone()),
11719 None,
11720 None,
11721 &AllowAllConsoleVisibilityPolicy,
11722 rpc_request_with_params(
11723 "mobkit/agent_memory/recall",
11724 json!({
11725 "identity": "identity:memory-console",
11726 "selection": "always"
11727 }),
11728 ),
11729 true,
11730 false,
11731 None,
11732 Some(&access_controller),
11733 Some(&denied_view),
11734 None,
11735 None,
11736 None,
11737 None,
11738 ))
11739 .await;
11740 assert_eq!(
11741 denied_recall["error"]["data"]["kind"],
11742 json!("access_denied"),
11743 "{denied_recall:#?}"
11744 );
11745 assert_eq!(
11748 denied_recall["error"]["data"]["action"],
11749 json!(ACTION_AGENT_MEMORY_READ),
11750 "{denied_recall:#?}"
11751 );
11752
11753 let send_only_controller = AccessController::new(crate::access::AccessControlConfig {
11754 enabled: true,
11755 admins: vec!["admin@example.test".to_string()],
11756 rules: vec![crate::access::AccessRule {
11757 id: "send-only-memory-console".to_string(),
11758 subjects: vec!["sender@example.test".to_string()],
11759 actions: vec![ACTION_AGENT_SEND.to_string()],
11760 agents: vec!["identity:memory-console".to_string()],
11761 ..crate::access::AccessRule::default()
11762 }],
11763 ..crate::access::AccessControlConfig::default()
11764 })?;
11765 let send_only_view = send_only_controller.view_for_subject(Some("sender@example.test"));
11766 let send_only_remember = Box::pin(handle_console_runtime_rpc_with_visibility(
11767 &runtime,
11768 None,
11769 None,
11770 None,
11771 None,
11772 None,
11773 Some(identity_runtime.clone()),
11774 None,
11775 None,
11776 &AllowAllConsoleVisibilityPolicy,
11777 rpc_request_with_params(
11778 "mobkit/agent_memory/remember",
11779 json!({
11780 "identity": "identity:memory-console",
11781 "title": "Send-only denied",
11782 "body": "Send-only users must not persist durable memory."
11783 }),
11784 ),
11785 true,
11786 false,
11787 None,
11788 Some(&send_only_controller),
11789 Some(&send_only_view),
11790 None,
11791 None,
11792 None,
11793 None,
11794 ))
11795 .await;
11796 assert_eq!(
11797 send_only_remember["error"]["data"]["kind"],
11798 json!("access_denied"),
11799 "{send_only_remember:#?}"
11800 );
11801 assert_eq!(
11802 send_only_remember["error"]["data"]["action"],
11803 json!(ACTION_AGENT_MEMORY_WRITE),
11804 "{send_only_remember:#?}"
11805 );
11806
11807 let send_only_forget = Box::pin(handle_console_runtime_rpc_with_visibility(
11808 &runtime,
11809 None,
11810 None,
11811 None,
11812 None,
11813 None,
11814 Some(identity_runtime.clone()),
11815 None,
11816 None,
11817 &AllowAllConsoleVisibilityPolicy,
11818 rpc_request_with_params(
11819 "mobkit/agent_memory/forget",
11820 json!({
11821 "identity": "identity:memory-console",
11822 "memory_id": memory_id.clone()
11823 }),
11824 ),
11825 true,
11826 false,
11827 None,
11828 Some(&send_only_controller),
11829 Some(&send_only_view),
11830 None,
11831 None,
11832 None,
11833 None,
11834 ))
11835 .await;
11836 assert_eq!(
11837 send_only_forget["error"]["data"]["kind"],
11838 json!("access_denied"),
11839 "{send_only_forget:#?}"
11840 );
11841 assert_eq!(
11842 send_only_forget["error"]["data"]["action"],
11843 json!(ACTION_AGENT_MEMORY_DELETE),
11844 "{send_only_forget:#?}"
11845 );
11846
11847 let wildcard_allow_exact_deny_controller =
11848 AccessController::new(crate::access::AccessControlConfig {
11849 enabled: true,
11850 admins: vec!["admin@example.test".to_string()],
11851 rules: vec![
11852 crate::access::AccessRule {
11853 id: "deny-canonical-memory-console".to_string(),
11854 effect: crate::access::AccessEffect::Deny,
11855 subjects: vec!["wildcard@example.test".to_string()],
11856 actions: vec![
11857 ACTION_AGENT_VIEW.to_string(),
11858 ACTION_AGENT_MEMORY_WRITE.to_string(),
11859 ACTION_AGENT_MEMORY_DELETE.to_string(),
11860 ],
11861 agents: vec!["identity:memory-console".to_string()],
11862 ..crate::access::AccessRule::default()
11863 },
11864 crate::access::AccessRule {
11865 id: "allow-wildcard-memory-console".to_string(),
11866 subjects: vec!["wildcard@example.test".to_string()],
11867 actions: vec![
11868 ACTION_AGENT_VIEW.to_string(),
11869 ACTION_AGENT_MEMORY_WRITE.to_string(),
11870 ACTION_AGENT_MEMORY_DELETE.to_string(),
11871 ],
11872 agents: vec!["*".to_string()],
11873 ..crate::access::AccessRule::default()
11874 },
11875 ],
11876 ..crate::access::AccessControlConfig::default()
11877 })?;
11878 let wildcard_allow_exact_deny_view =
11879 wildcard_allow_exact_deny_controller.view_for_subject(Some("wildcard@example.test"));
11880
11881 let whitespace_denied_remember = Box::pin(handle_console_runtime_rpc_with_visibility(
11882 &runtime,
11883 None,
11884 None,
11885 None,
11886 None,
11887 None,
11888 Some(identity_runtime.clone()),
11889 None,
11890 None,
11891 &AllowAllConsoleVisibilityPolicy,
11892 rpc_request_with_params(
11893 "mobkit/agent_memory/remember",
11894 json!({
11895 "identity": " identity:memory-console ",
11896 "title": "Whitespace denied",
11897 "body": "Whitespace-wrapped identities must be authorized canonically."
11898 }),
11899 ),
11900 true,
11901 false,
11902 None,
11903 Some(&wildcard_allow_exact_deny_controller),
11904 Some(&wildcard_allow_exact_deny_view),
11905 None,
11906 None,
11907 None,
11908 None,
11909 ))
11910 .await;
11911 assert_eq!(
11912 whitespace_denied_remember["error"]["data"]["kind"],
11913 json!("access_denied"),
11914 "{whitespace_denied_remember:#?}"
11915 );
11916 assert_eq!(
11917 whitespace_denied_remember["error"]["data"]["action"],
11918 json!(ACTION_AGENT_MEMORY_WRITE),
11919 "{whitespace_denied_remember:#?}"
11920 );
11921
11922 let whitespace_denied_recall = Box::pin(handle_console_runtime_rpc_with_visibility(
11923 &runtime,
11924 None,
11925 None,
11926 None,
11927 None,
11928 None,
11929 Some(identity_runtime.clone()),
11930 None,
11931 None,
11932 &AllowAllConsoleVisibilityPolicy,
11933 rpc_request_with_params(
11934 "mobkit/agent_memory/recall",
11935 json!({
11936 "identity": " identity:memory-console ",
11937 "selection": "always"
11938 }),
11939 ),
11940 true,
11941 false,
11942 None,
11943 Some(&wildcard_allow_exact_deny_controller),
11944 Some(&wildcard_allow_exact_deny_view),
11945 None,
11946 None,
11947 None,
11948 None,
11949 ))
11950 .await;
11951 assert_eq!(
11952 whitespace_denied_recall["error"]["data"]["kind"],
11953 json!("access_denied"),
11954 "{whitespace_denied_recall:#?}"
11955 );
11956 assert_eq!(
11959 whitespace_denied_recall["error"]["data"]["action"],
11960 json!(ACTION_AGENT_MEMORY_READ),
11961 "{whitespace_denied_recall:#?}"
11962 );
11963
11964 let whitespace_denied_forget = Box::pin(handle_console_runtime_rpc_with_visibility(
11965 &runtime,
11966 None,
11967 None,
11968 None,
11969 None,
11970 None,
11971 Some(identity_runtime.clone()),
11972 None,
11973 None,
11974 &AllowAllConsoleVisibilityPolicy,
11975 rpc_request_with_params(
11976 "mobkit/agent_memory/forget",
11977 json!({
11978 "identity": " identity:memory-console ",
11979 "memory_id": memory_id.clone()
11980 }),
11981 ),
11982 true,
11983 false,
11984 None,
11985 Some(&wildcard_allow_exact_deny_controller),
11986 Some(&wildcard_allow_exact_deny_view),
11987 None,
11988 None,
11989 None,
11990 None,
11991 ))
11992 .await;
11993 assert_eq!(
11994 whitespace_denied_forget["error"]["data"]["kind"],
11995 json!("access_denied"),
11996 "{whitespace_denied_forget:#?}"
11997 );
11998 assert_eq!(
11999 whitespace_denied_forget["error"]["data"]["action"],
12000 json!(ACTION_AGENT_MEMORY_DELETE),
12001 "{whitespace_denied_forget:#?}"
12002 );
12003
12004 let forget = Box::pin(handle_console_runtime_rpc(
12005 &runtime,
12006 None,
12007 None,
12008 None,
12009 None,
12010 None,
12011 Some(identity_runtime.clone()),
12012 None,
12013 None,
12014 rpc_request_with_params(
12015 "mobkit/agent_memory/forget",
12016 json!({
12017 "identity": "identity:memory-console",
12018 "memory_id": memory_id
12019 }),
12020 ),
12021 true,
12022 ))
12023 .await;
12024 assert_eq!(forget["error"], Value::Null, "{forget:#?}");
12025 assert_eq!(forget["result"]["deleted"], json!(true));
12026
12027 let after_forget = Box::pin(handle_console_runtime_rpc(
12028 &runtime,
12029 None,
12030 None,
12031 None,
12032 None,
12033 None,
12034 Some(identity_runtime),
12035 None,
12036 None,
12037 rpc_request_with_params(
12038 "mobkit/agent_memory/recall",
12039 json!({
12040 "identity": "identity:memory-console",
12041 "selection": "always"
12042 }),
12043 ),
12044 false,
12045 ))
12046 .await;
12047 assert_eq!(after_forget["error"], Value::Null, "{after_forget:#?}");
12048 assert_eq!(
12049 after_forget["result"]["records"]
12050 .as_array()
12051 .map(std::vec::Vec::len),
12052 Some(0)
12053 );
12054
12055 let _ = runtime.handle().stop().await;
12056 Ok(())
12057 }
12058
12059 #[tokio::test]
12060 async fn console_runtime_capabilities_advertise_respawn_and_reset_without_identity_runtime()
12061 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12062 let (_temp_dir, runtime) =
12063 build_empty_console_test_runtime("console-plain-lifecycle-capabilities").await?;
12064
12065 let response = Box::pin(handle_console_runtime_rpc(
12069 &runtime,
12070 None,
12071 None,
12072 None,
12073 None,
12074 None,
12075 None,
12076 None,
12077 None,
12078 rpc_request("mobkit/capabilities"),
12079 true,
12080 ))
12081 .await;
12082 assert_eq!(response["error"], Value::Null, "{response:#?}");
12083 let methods = response["result"]["methods"]
12084 .as_array()
12085 .ok_or("capabilities methods should be an array")?;
12086 for method in ["mobkit/retire", "mobkit/respawn", "mobkit/reset"] {
12087 assert!(
12088 methods.iter().any(|candidate| candidate == method),
12089 "plain runtime capabilities should advertise {method}: {methods:#?}"
12090 );
12091 }
12092 assert!(
12093 !methods
12094 .iter()
12095 .any(|candidate| candidate == "mobkit/delete_identity"),
12096 "delete_identity requires an identity runtime: {methods:#?}"
12097 );
12098
12099 let read_only_response = Box::pin(handle_console_runtime_rpc(
12102 &runtime,
12103 None,
12104 None,
12105 None,
12106 None,
12107 None,
12108 None,
12109 None,
12110 None,
12111 rpc_request("mobkit/capabilities"),
12112 false,
12113 ))
12114 .await;
12115 assert_eq!(
12116 read_only_response["error"],
12117 Value::Null,
12118 "{read_only_response:#?}"
12119 );
12120 let read_only_methods = read_only_response["result"]["methods"]
12121 .as_array()
12122 .ok_or("capabilities methods should be an array")?;
12123 for method in ["mobkit/retire", "mobkit/respawn", "mobkit/reset"] {
12124 assert!(
12125 !read_only_methods
12126 .iter()
12127 .any(|candidate| candidate == method),
12128 "unauthenticated capabilities must omit {method}: {read_only_methods:#?}"
12129 );
12130 }
12131
12132 let _ = runtime.handle().stop().await;
12133 Ok(())
12134 }
12135
12136 #[tokio::test]
12137 async fn console_aggregator_rpc_does_not_expose_flow_editor_authoring_methods() {
12138 let response = Box::pin(handle_console_aggregator_rpc(
12139 None,
12140 rpc_request("mobkit/capabilities"),
12141 true,
12142 false,
12143 None,
12144 None,
12145 ))
12146 .await;
12147
12148 assert_eq!(response["error"], Value::Null, "{response:#?}");
12149 let methods = response["result"]["methods"]
12150 .as_array()
12151 .expect("capabilities methods should be an array");
12152 for method in crate::rpc::MOBPACK_AUTHORING_METHODS {
12153 assert!(
12154 !methods.iter().any(|candidate| candidate == method),
12155 "console aggregator capabilities must not advertise flow-editor method {method}: {methods:#?}"
12156 );
12157 }
12158
12159 let mobpack_response = Box::pin(handle_console_aggregator_rpc(
12160 None,
12161 rpc_request("mobkit/mobpacks/schema"),
12162 true,
12163 false,
12164 None,
12165 None,
12166 ))
12167 .await;
12168 assert_eq!(
12169 mobpack_response["error"]["code"],
12170 json!(-32601),
12171 "console aggregator RPC must not handle flow-editor authoring methods: {mobpack_response:#?}"
12172 );
12173 }
12174
12175 #[tokio::test]
12176 async fn console_runtime_identity_reads_reject_stale_runtime_aliases()
12177 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12178 let (_temp_dir, runtime) =
12179 build_empty_console_test_runtime("console-identity-stale-read-alias").await?;
12180 let mut labels = BTreeMap::new();
12181 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
12182 runtime
12183 .handle()
12184 .spawn_spec(
12185 SpawnMemberSpec::from_wire(
12186 "worker".to_string(),
12187 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
12190 Some("You are the stale Review Agent.".into()),
12191 None,
12192 None,
12193 )
12194 .with_labels(labels),
12195 )
12196 .await?;
12197
12198 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12199 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12200 lease_provider: Arc::new(LocalLeaseProvider::new()),
12201 runtime_instance_id: "console-identity-stale-read-alias".to_string(),
12202 has_runtime_store: true,
12203 durability_policy: DurabilityPolicy::SyncWriteThrough,
12204 bridge: None,
12205 default_timeout: None,
12206 }));
12207 let identity = AgentIdentity::parse("review:singleton")?;
12208 let record = ContinuityRecord {
12209 identity: identity.clone(),
12210 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:1")?,
12211 session_id: meerkat_core::types::SessionId::new(),
12212 generation: ContinuityGeneration::new(1),
12213 checkpoint_version: CheckpointVersion::new(0),
12214 };
12215 identity_runtime
12216 .register(
12217 DurableAgentSpec {
12218 identity: identity.clone(),
12219 profile: ProfileName::from("worker"),
12220 addressability: AgentAddressability::Addressable,
12221 display_name: None,
12222 labels: BTreeMap::new(),
12223 context: None,
12224 additional_instructions: Vec::new(),
12225 initial_message: None,
12226 runtime_mode_override: None,
12227 backend: None,
12228 binding: None,
12229 },
12230 IdentityLifecycleState::Active,
12231 Some(record),
12232 Some(LeaseGrant {
12233 identity,
12234 fencing_token: FencingToken::new(7),
12235 ttl: Duration::from_mins(1),
12236 }),
12237 )
12238 .await;
12239
12240 for requested_identity in ["rt:review:singleton:0", "review:singleton"] {
12241 for method in ["mobkit/status_identity", "mobkit/inspect_identity"] {
12242 let response = Box::pin(handle_console_runtime_rpc(
12243 &runtime,
12244 None,
12245 None,
12246 None,
12247 None,
12248 None,
12249 Some(identity_runtime.clone()),
12250 None,
12251 None,
12252 rpc_request_with_params(method, json!({ "identity": requested_identity })),
12253 true,
12254 ))
12255 .await;
12256 assert_ne!(
12257 response["error"],
12258 Value::Null,
12259 "{method} must reject stale alias for {requested_identity}"
12260 );
12261 let message = response["error"]["message"].as_str().unwrap_or_default();
12262 assert!(
12263 message.contains(
12264 "identity runtime binding for review:singleton points at rt:review:singleton:1"
12265 ),
12266 "unexpected stale-alias message for {method}/{requested_identity}: {message}"
12267 );
12268 assert_eq!(
12269 response["error"]["data"]["kind"],
12270 json!("stale_identity_runtime_binding")
12271 );
12272 assert_eq!(
12273 response["error"]["data"]["registered_runtime_member_id"],
12274 json!("rt:review:singleton:1")
12275 );
12276 assert_eq!(
12277 response["error"]["data"]["live_runtime_member_id"],
12278 json!("rt:review:singleton:0")
12279 );
12280 }
12281 }
12282
12283 let list_response = Box::pin(handle_console_runtime_rpc(
12284 &runtime,
12285 None,
12286 None,
12287 None,
12288 None,
12289 None,
12290 Some(identity_runtime.clone()),
12291 None,
12292 None,
12293 rpc_request("mobkit/list_members"),
12294 true,
12295 ))
12296 .await;
12297 let listed = list_response["result"].as_array().expect("list members");
12298 assert!(
12299 listed
12300 .iter()
12301 .all(|entry| entry["agent_identity"] != json!("rt:review:singleton:0")),
12302 "list_members must filter stale runtime aliases: {list_response:#?}"
12303 );
12304
12305 let find_response = Box::pin(handle_console_runtime_rpc(
12306 &runtime,
12307 None,
12308 None,
12309 None,
12310 None,
12311 None,
12312 Some(identity_runtime.clone()),
12313 None,
12314 None,
12315 rpc_request_with_params(
12316 "mobkit/find_members",
12317 json!({ "label_key": "agent_identity", "label_value": "review:singleton" }),
12318 ),
12319 true,
12320 ))
12321 .await;
12322 let found = find_response["result"].as_array().expect("find members");
12323 assert!(
12324 found
12325 .iter()
12326 .all(|entry| entry["agent_identity"] != json!("rt:review:singleton:0")),
12327 "find_members must filter stale runtime aliases: {find_response:#?}"
12328 );
12329
12330 for method in [
12331 "mobkit/get_member",
12332 "mobkit/member_status",
12333 "mobkit/identity/resolved_tools",
12334 "mobkit/retire_member",
12335 "mobkit/respawn_member",
12336 "mobkit/force_cancel_member",
12337 ] {
12338 let param_name = if method == "mobkit/identity/resolved_tools" {
12339 "identity"
12340 } else {
12341 "member_id"
12342 };
12343 let response = Box::pin(handle_console_runtime_rpc(
12344 &runtime,
12345 None,
12346 None,
12347 None,
12348 None,
12349 None,
12350 Some(identity_runtime.clone()),
12351 None,
12352 None,
12353 rpc_request_with_params(method, json!({ param_name: "rt:review:singleton:0" })),
12354 true,
12355 ))
12356 .await;
12357 assert_ne!(
12358 response["error"],
12359 Value::Null,
12360 "{method} must reject stale runtime alias"
12361 );
12362 assert_eq!(
12363 response["error"]["data"]["kind"],
12364 json!("stale_identity_runtime_binding")
12365 );
12366 assert_eq!(
12367 response["error"]["data"]["live_runtime_member_id"],
12368 json!("rt:review:singleton:0")
12369 );
12370 }
12371
12372 let (_temp_dir_without_stale, runtime_without_stale) =
12373 build_empty_console_test_runtime("console-identity-no-live-stale-alias").await?;
12374
12375 for method in [
12376 "mobkit/status_identity",
12377 "mobkit/inspect_identity",
12378 "mobkit/retire",
12379 "mobkit/respawn",
12380 "mobkit/reset",
12381 ] {
12382 let response = Box::pin(handle_console_runtime_rpc(
12383 &runtime_without_stale,
12384 None,
12385 None,
12386 None,
12387 Some(ConsoleEventStore::new()),
12388 None,
12389 Some(identity_runtime.clone()),
12390 None,
12391 None,
12392 rpc_request_with_params(method, json!({ "identity": "rt:review:singleton:0" })),
12393 true,
12394 ))
12395 .await;
12396 assert_ne!(
12397 response["error"],
12398 Value::Null,
12399 "{method} must reject stale synthetic runtime alias"
12400 );
12401 assert!(
12402 response["error"]["message"]
12403 .as_str()
12404 .unwrap_or_default()
12405 .contains("identity not found: rt:review:singleton:0"),
12406 "unexpected no-live stale-alias response for {method}: {response:#?}"
12407 );
12408 }
12409 let _ = runtime_without_stale.handle().stop().await;
12410
12411 let _ = runtime.handle().stop().await;
12412 Ok(())
12413 }
12414
12415 #[tokio::test]
12416 async fn aggregator_live_snapshot_projects_identity_first_topology_peers()
12417 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12418 let (_temp_dir, mob_runtime) =
12419 build_empty_console_test_runtime("identity-topology-snapshot-test").await?;
12420 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12421 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12422 lease_provider: Arc::new(LocalLeaseProvider::new()),
12423 runtime_instance_id: "console-topology-snapshot-test".to_string(),
12424 has_runtime_store: true,
12425 durability_policy: DurabilityPolicy::SyncWriteThrough,
12426 bridge: None,
12427 default_timeout: None,
12428 }));
12429
12430 for name in ["agent:alpha", "agent:beta"] {
12431 let identity = AgentIdentity::parse(name)?;
12432 let record = ContinuityRecord {
12433 identity: identity.clone(),
12434 agent_runtime_id: AgentRuntimeId::parse(&format!("rt:{name}:0"))?,
12435 session_id: meerkat_core::types::SessionId::new(),
12436 generation: ContinuityGeneration::new(0),
12437 checkpoint_version: CheckpointVersion::new(0),
12438 };
12439 identity_runtime
12440 .register(
12441 DurableAgentSpec {
12442 identity: identity.clone(),
12443 profile: ProfileName::from("default"),
12444 addressability: AgentAddressability::Addressable,
12445 display_name: None,
12446 labels: BTreeMap::new(),
12447 context: None,
12448 additional_instructions: Vec::new(),
12449 initial_message: None,
12450 runtime_mode_override: None,
12451 backend: None,
12452 binding: None,
12453 },
12454 IdentityLifecycleState::Active,
12455 Some(record),
12456 Some(LeaseGrant {
12457 identity,
12458 fencing_token: FencingToken::new(7),
12459 ttl: Duration::from_mins(1),
12460 }),
12461 )
12462 .await;
12463 }
12464 identity_runtime
12465 .set_desired_peer_edges(vec![ManagedPeerEdge::new(
12466 AgentIdentity::parse("agent:alpha")?,
12467 AgentIdentity::parse("agent:beta")?,
12468 )?])
12469 .await;
12470
12471 let aggregator = MobKitConsoleAggregator::in_memory();
12472 aggregator.register_runtime_handles_with_policy(
12473 "identity-first",
12474 "",
12475 mob_runtime.clone(),
12476 Some(identity_runtime),
12477 ConsoleEventStore::new(),
12478 Arc::new(AllowAllConsoleVisibilityPolicy),
12479 );
12480
12481 let snapshot = build_aggregator_live_snapshot(&aggregator, &[]).await?;
12482 let alpha = snapshot
12483 .members
12484 .iter()
12485 .find(|member| member.agent_identity == "agent:alpha")
12486 .ok_or("agent:alpha missing from live snapshot")?;
12487 assert_eq!(alpha.wired_to, vec!["agent:beta".to_string()]);
12488
12489 let _ = mob_runtime.handle().stop().await;
12490 Ok(())
12491 }
12492
12493 #[tokio::test]
12494 async fn identity_first_console_send_reserves_timeline_and_uses_identity_runtime()
12495 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12496 let (_temp_dir, mob_runtime) =
12497 build_empty_console_test_runtime("identity-send-runtime-key-test").await?;
12498 let identity = AgentIdentity::parse("agent:console")?;
12499 let record = ContinuityRecord {
12500 identity: identity.clone(),
12501 agent_runtime_id: AgentRuntimeId::parse("rt:agent:console:0")?,
12502 session_id: meerkat_core::types::SessionId::new(),
12503 generation: ContinuityGeneration::new(0),
12504 checkpoint_version: CheckpointVersion::new(0),
12505 };
12506 let runtime = IdentityRuntime::new(IdentityRuntimeConfig {
12507 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12508 lease_provider: Arc::new(LocalLeaseProvider::new()),
12509 runtime_instance_id: "console-test".to_string(),
12510 has_runtime_store: true,
12511 durability_policy: DurabilityPolicy::SyncWriteThrough,
12512 bridge: None,
12513 default_timeout: None,
12514 });
12515 runtime
12516 .register(
12517 DurableAgentSpec {
12518 identity: identity.clone(),
12519 profile: ProfileName::from("default"),
12520 addressability: AgentAddressability::Addressable,
12521 display_name: None,
12522 labels: BTreeMap::new(),
12523 context: None,
12524 additional_instructions: Vec::new(),
12525 initial_message: None,
12526 runtime_mode_override: None,
12527 backend: None,
12528 binding: None,
12529 },
12530 IdentityLifecycleState::Active,
12531 Some(record.clone()),
12532 Some(LeaseGrant {
12533 identity: identity.clone(),
12534 fencing_token: FencingToken::new(7),
12535 ttl: Duration::from_mins(1),
12536 }),
12537 )
12538 .await;
12539
12540 let aggregator = MobKitConsoleAggregator::in_memory();
12541 let events = ConsoleEventStore::new();
12542 let runtime = Arc::new(runtime);
12543 aggregator.register_runtime_handles_with_policy(
12544 "default",
12545 "",
12546 mob_runtime.clone(),
12547 Some(runtime.clone()),
12548 events.clone(),
12549 Arc::new(AllowAllConsoleVisibilityPolicy),
12550 );
12551 let accepted = console_send_identity_first(
12552 &aggregator,
12553 runtime.clone(),
12554 Some(&mob_runtime),
12555 Some(&events),
12556 crate::console_aggregator::ConsoleSendRequest {
12557 identity: identity.as_str().to_string(),
12558 content: serde_json::to_value(meerkat_core::ContentInput::Text(
12559 "hello".to_string(),
12560 ))?,
12561 origin: "test".to_string(),
12562 idempotency_key: "idem-1".to_string(),
12563 handling_mode: None,
12564 },
12565 )
12566 .await?;
12567
12568 assert_eq!(accepted.identity, identity.as_str());
12569 assert_eq!(accepted.status, ConsoleFrameStatus::Accepted);
12570 assert_eq!(accepted.session_id, Some(record.session_id.to_string()));
12571
12572 let page = aggregator
12573 .query_timeline(ConsoleTimelineQuery {
12574 identity: Some(identity.as_str().to_string()),
12575 ..ConsoleTimelineQuery::default()
12576 })
12577 .await?;
12578 assert_eq!(page.frames.len(), 1);
12579 assert_eq!(page.frames[0].runtime_key, "default");
12580 assert_eq!(page.frames[0].status, ConsoleFrameStatus::Accepted);
12581 assert_eq!(
12582 page.frames[0].session_id,
12583 Some(record.session_id.to_string())
12584 );
12585 let _ = mob_runtime.handle().stop().await;
12586 Ok(())
12587 }
12588
12589 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
12590 async fn identity_first_console_send_falls_back_to_member_only_spawned_worker()
12591 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12592 let (_temp_dir, mob_runtime) =
12593 build_empty_console_test_runtime("identity-send-member-only-test").await?;
12594 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12595 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12596 lease_provider: Arc::new(LocalLeaseProvider::new()),
12597 runtime_instance_id: "console-member-only-send-test".to_string(),
12598 has_runtime_store: true,
12599 durability_policy: DurabilityPolicy::SyncWriteThrough,
12600 bridge: None,
12601 default_timeout: None,
12602 }));
12603
12604 let aggregator = MobKitConsoleAggregator::in_memory();
12605 let events = ConsoleEventStore::new();
12606 aggregator.register_runtime_handles_with_policy(
12607 "identity-first",
12608 "",
12609 mob_runtime.clone(),
12610 Some(identity_runtime.clone()),
12611 events.clone(),
12612 Arc::new(AllowAllConsoleVisibilityPolicy),
12613 );
12614
12615 mob_runtime
12616 .handle()
12617 .spawn_spec(SpawnMemberSpec::from_wire(
12618 "worker".to_string(),
12619 crate::member_comms_id::mob_member_id_str("agent:member-only").into_owned(),
12622 Some("You are a member-only spawned worker.".into()),
12623 None,
12624 None,
12625 ))
12626 .await?;
12627
12628 let accepted = console_send_with_identity_first_fallback(
12629 &aggregator,
12630 identity_runtime,
12631 Some(&mob_runtime),
12632 Some(&events),
12633 crate::console_aggregator::ConsoleSendRequest {
12634 identity: "agent:member-only".to_string(),
12635 content: serde_json::to_value(meerkat_core::ContentInput::Text(
12636 "hello spawned worker".to_string(),
12637 ))?,
12638 origin: "test".to_string(),
12639 idempotency_key: "member-only-idem-1".to_string(),
12640 handling_mode: None,
12641 },
12642 )
12643 .await?;
12644
12645 assert_eq!(accepted.identity, "agent:member-only");
12646 assert!(accepted.session_id.is_some());
12647
12648 let page = aggregator
12649 .query_timeline(ConsoleTimelineQuery {
12650 identity: Some("agent:member-only".to_string()),
12651 ..ConsoleTimelineQuery::default()
12652 })
12653 .await?;
12654 assert!(
12655 page.frames.iter().any(|frame| frame.kind == "user_input"),
12656 "fallback send should persist a user input frame for the member-only worker: {page:#?}"
12657 );
12658
12659 let _ = mob_runtime.handle().stop().await;
12660 Ok(())
12661 }
12662
12663 #[tokio::test]
12664 async fn identity_first_console_send_returns_before_bridge_delivery_completes()
12665 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12666 let identity = AgentIdentity::parse("agent:slow-console")?;
12667 let record = ContinuityRecord {
12668 identity: identity.clone(),
12669 agent_runtime_id: AgentRuntimeId::parse("rt:agent:slow-console:0")?,
12670 session_id: meerkat_core::types::SessionId::new(),
12671 generation: ContinuityGeneration::new(0),
12672 checkpoint_version: CheckpointVersion::new(0),
12673 };
12674 let deliver_calls = Arc::new(AtomicUsize::new(0));
12675 let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12676 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12677 lease_provider: Arc::new(LocalLeaseProvider::new()),
12678 runtime_instance_id: "console-slow-send-test".to_string(),
12679 has_runtime_store: true,
12680 durability_policy: DurabilityPolicy::SyncWriteThrough,
12681 bridge: Some(Arc::new(BlockingIdentityBridge {
12682 deliver_calls: deliver_calls.clone(),
12683 })),
12684 default_timeout: None,
12685 }));
12686 runtime
12687 .register(
12688 DurableAgentSpec {
12689 identity: identity.clone(),
12690 profile: ProfileName::from("default"),
12691 addressability: AgentAddressability::Addressable,
12692 display_name: None,
12693 labels: BTreeMap::new(),
12694 context: None,
12695 additional_instructions: Vec::new(),
12696 initial_message: None,
12697 runtime_mode_override: None,
12698 backend: None,
12699 binding: None,
12700 },
12701 IdentityLifecycleState::Active,
12702 Some(record.clone()),
12703 Some(LeaseGrant {
12704 identity: identity.clone(),
12705 fencing_token: FencingToken::new(7),
12706 ttl: Duration::from_mins(1),
12707 }),
12708 )
12709 .await;
12710
12711 let aggregator = MobKitConsoleAggregator::in_memory();
12712 let accepted = match tokio::time::timeout(
12713 Duration::from_millis(100),
12714 console_send_identity_first(
12715 &aggregator,
12716 runtime,
12717 None,
12718 None,
12719 crate::console_aggregator::ConsoleSendRequest {
12720 identity: identity.as_str().to_string(),
12721 content: serde_json::to_value(meerkat_core::ContentInput::Text(
12722 "hello slow bridge".to_string(),
12723 ))?,
12724 origin: "test".to_string(),
12725 idempotency_key: "idem-slow-bridge".to_string(),
12726 handling_mode: None,
12727 },
12728 ),
12729 )
12730 .await
12731 {
12732 Ok(Ok(accepted)) => accepted,
12733 Ok(Err(err)) => return Err(format!("send should be accepted: {err}").into()),
12734 Err(err) => {
12735 return Err(
12736 format!("console send should not wait for bridge delivery: {err}").into(),
12737 );
12738 }
12739 };
12740
12741 assert_eq!(accepted.status, ConsoleFrameStatus::Accepted);
12742 if tokio::time::timeout(Duration::from_millis(100), async {
12743 while deliver_calls.load(Ordering::SeqCst) == 0 {
12744 tokio::time::sleep(Duration::from_millis(5)).await;
12745 }
12746 })
12747 .await
12748 .is_err()
12749 {
12750 return Err("delivery should be spawned in the background".into());
12751 }
12752 Ok(())
12753 }
12754
12755 #[tokio::test]
12756 async fn identity_first_console_steer_waits_for_bridge_delivery()
12757 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12758 let identity = AgentIdentity::parse("agent:slow-steer-console")?;
12759 let record = ContinuityRecord {
12760 identity: identity.clone(),
12761 agent_runtime_id: AgentRuntimeId::parse("rt:agent:slow-steer-console:0")?,
12762 session_id: meerkat_core::types::SessionId::new(),
12763 generation: ContinuityGeneration::new(0),
12764 checkpoint_version: CheckpointVersion::new(0),
12765 };
12766 let deliver_calls = Arc::new(AtomicUsize::new(0));
12767 let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12768 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12769 lease_provider: Arc::new(LocalLeaseProvider::new()),
12770 runtime_instance_id: "console-slow-steer-send-test".to_string(),
12771 has_runtime_store: true,
12772 durability_policy: DurabilityPolicy::SyncWriteThrough,
12773 bridge: Some(Arc::new(BlockingIdentityBridge {
12774 deliver_calls: deliver_calls.clone(),
12775 })),
12776 default_timeout: None,
12777 }));
12778 runtime
12779 .register(
12780 DurableAgentSpec {
12781 identity: identity.clone(),
12782 profile: ProfileName::from("default"),
12783 addressability: AgentAddressability::Addressable,
12784 display_name: None,
12785 labels: BTreeMap::new(),
12786 context: None,
12787 additional_instructions: Vec::new(),
12788 initial_message: None,
12789 runtime_mode_override: None,
12790 backend: None,
12791 binding: None,
12792 },
12793 IdentityLifecycleState::Active,
12794 Some(record),
12795 Some(LeaseGrant {
12796 identity: identity.clone(),
12797 fencing_token: FencingToken::new(7),
12798 ttl: Duration::from_mins(1),
12799 }),
12800 )
12801 .await;
12802
12803 let aggregator = MobKitConsoleAggregator::in_memory();
12804 let result = tokio::time::timeout(
12805 Duration::from_millis(100),
12806 console_send_identity_first(
12807 &aggregator,
12808 runtime,
12809 None,
12810 None,
12811 crate::console_aggregator::ConsoleSendRequest {
12812 identity: identity.as_str().to_string(),
12813 content: serde_json::to_value(meerkat_core::ContentInput::Text(
12814 "hello slow steer bridge".to_string(),
12815 ))?,
12816 origin: "test".to_string(),
12817 idempotency_key: "idem-slow-steer-bridge".to_string(),
12818 handling_mode: Some("steer".to_string()),
12819 },
12820 ),
12821 )
12822 .await;
12823
12824 if result.is_ok() {
12825 return Err("steer send must wait for bridge delivery admission".into());
12826 }
12827 assert_eq!(
12828 deliver_calls.load(Ordering::SeqCst),
12829 1,
12830 "steer delivery should have reached the bridge before the console response waits"
12831 );
12832 Ok(())
12833 }
12834
12835 #[tokio::test]
12836 async fn identity_first_console_send_forwards_handling_mode()
12837 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12838 let identity = AgentIdentity::parse("agent:mode-console")?;
12839 let record = ContinuityRecord {
12840 identity: identity.clone(),
12841 agent_runtime_id: AgentRuntimeId::parse("rt:agent:mode-console:0")?,
12842 session_id: meerkat_core::types::SessionId::new(),
12843 generation: ContinuityGeneration::new(0),
12844 checkpoint_version: CheckpointVersion::new(0),
12845 };
12846 let handling_modes = Arc::new(Mutex::new(Vec::new()));
12847 let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12848 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12849 lease_provider: Arc::new(LocalLeaseProvider::new()),
12850 runtime_instance_id: "console-mode-send-test".to_string(),
12851 has_runtime_store: true,
12852 durability_policy: DurabilityPolicy::SyncWriteThrough,
12853 bridge: Some(Arc::new(RecordingIdentityBridge {
12854 session_id: record.session_id.clone(),
12855 handling_modes: handling_modes.clone(),
12856 })),
12857 default_timeout: None,
12858 }));
12859 runtime
12860 .register(
12861 DurableAgentSpec {
12862 identity: identity.clone(),
12863 profile: ProfileName::from("default"),
12864 addressability: AgentAddressability::Addressable,
12865 display_name: None,
12866 labels: BTreeMap::new(),
12867 context: None,
12868 additional_instructions: Vec::new(),
12869 initial_message: None,
12870 runtime_mode_override: None,
12871 backend: None,
12872 binding: None,
12873 },
12874 IdentityLifecycleState::Active,
12875 Some(record),
12876 Some(LeaseGrant {
12877 identity: identity.clone(),
12878 fencing_token: FencingToken::new(7),
12879 ttl: Duration::from_mins(1),
12880 }),
12881 )
12882 .await;
12883
12884 let aggregator = MobKitConsoleAggregator::in_memory();
12885 let accepted = console_send_identity_first(
12886 &aggregator,
12887 runtime,
12888 None,
12889 None,
12890 crate::console_aggregator::ConsoleSendRequest {
12891 identity: identity.as_str().to_string(),
12892 content: serde_json::to_value(meerkat_core::ContentInput::Text(
12893 "hello steer bridge".to_string(),
12894 ))?,
12895 origin: "test".to_string(),
12896 idempotency_key: "idem-steer-bridge".to_string(),
12897 handling_mode: Some("steer".to_string()),
12898 },
12899 )
12900 .await?;
12901
12902 if tokio::time::timeout(Duration::from_millis(100), async {
12903 loop {
12904 if handling_modes
12905 .lock()
12906 .map(|modes| modes.contains(&HandlingMode::Steer))
12907 .unwrap_or(false)
12908 {
12909 break;
12910 }
12911 tokio::time::sleep(Duration::from_millis(5)).await;
12912 }
12913 })
12914 .await
12915 .is_err()
12916 {
12917 return Err("identity-first console send should forward steer mode".into());
12918 }
12919
12920 let terminal_frame = tokio::time::timeout(Duration::from_millis(500), async {
12921 loop {
12922 let page = aggregator
12923 .query_timeline(ConsoleTimelineQuery {
12924 identity: Some(identity.as_str().to_string()),
12925 ..ConsoleTimelineQuery::default()
12926 })
12927 .await
12928 .map_err(|err| format!("query timeline: {err}"))?;
12929 if page.frames.iter().any(|frame| {
12930 frame.kind == "interaction_complete"
12931 && frame.interaction_id.as_deref() == Some(accepted.interaction_id.as_str())
12932 && frame.payload.get("reason").and_then(Value::as_str)
12933 == Some("steer_delivered")
12934 }) {
12935 return Ok::<(), String>(());
12936 }
12937 tokio::time::sleep(Duration::from_millis(5)).await;
12938 }
12939 })
12940 .await;
12941 match terminal_frame {
12942 Ok(Ok(())) => {}
12943 Ok(Err(err)) => return Err(err.into()),
12944 Err(_) => {
12945 return Err(
12946 "identity-first steer send should terminalize its console interaction".into(),
12947 );
12948 }
12949 }
12950 Ok(())
12951 }
12952
12953 #[test]
12954 fn multipart_body_limit_covers_configured_image_limit() {
12955 const _: () = assert!(MAX_MULTIPART_BODY_BYTES > MAX_MULTIPART_IMAGE_BYTES);
12956 const _: () = assert!(MAX_MULTIPART_BODY_BYTES > 2 * 1024 * 1024);
12957 }
12958
12959 #[tokio::test]
12972 async fn cold_cache_waiter_resumes_when_refresh_lock_drops()
12973 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12974 use std::sync::atomic::Ordering;
12975 use tokio::time::Duration;
12976
12977 let model = ConsoleSnapshotReadModel::default();
12978 let guard = model
12979 .refresh_lock
12980 .clone()
12981 .try_lock_owned()
12982 .map_err(|_| "refresh_lock unexpectedly contended at test start")?;
12983
12984 let model_for_waiter = model.clone();
12985 let waiter = tokio::spawn(async move {
12986 if model_for_waiter
12989 .primed
12990 .load(std::sync::atomic::Ordering::Acquire)
12991 {
12992 return;
12993 }
12994 let _wait_guard = model_for_waiter.refresh_lock.clone().lock_owned().await;
12995 assert!(
12998 model_for_waiter
12999 .primed
13000 .load(std::sync::atomic::Ordering::Acquire),
13001 "waiter acquired lock but primed is still false"
13002 );
13003 });
13004
13005 tokio::time::sleep(Duration::from_millis(20)).await;
13007
13008 model.primed.store(true, Ordering::Release);
13011 drop(guard);
13012
13013 let result = tokio::time::timeout(Duration::from_secs(1), waiter).await;
13014 assert!(
13015 result.is_ok(),
13016 "waiter should resume once the refresh lock drops"
13017 );
13018 Ok(())
13019 }
13020
13021 #[tokio::test]
13026 async fn snapshot_skips_refresh_lock_when_already_primed()
13027 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13028 use std::sync::atomic::Ordering;
13029 use tokio::time::Duration;
13030
13031 let model = ConsoleSnapshotReadModel::default();
13032 model.primed.store(true, Ordering::Release);
13033 let _guard = model
13035 .refresh_lock
13036 .clone()
13037 .try_lock_owned()
13038 .map_err(|_| "refresh_lock unexpectedly contended at test start")?;
13039
13040 let snap_fast_path = async {
13046 assert!(
13047 model.primed.load(Ordering::Acquire),
13048 "primed precondition for hot-cache path"
13049 );
13050 };
13051 let result = tokio::time::timeout(Duration::from_millis(100), snap_fast_path).await;
13052 assert!(result.is_ok(), "hot-cache snapshot path should not block");
13053 Ok(())
13054 }
13055
13056 #[tokio::test]
13057 async fn console_aggregator_reset_all_rpc_rejects_destructive_retire_all_semantics()
13058 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13059 let (_temp_dir, runtime) =
13060 build_empty_console_test_runtime("console-reset-fresh-identity-cache").await?;
13061 let aggregator = MobKitConsoleAggregator::in_memory();
13062 aggregator.register_runtime_handles_with_policy(
13063 "runtime-reset",
13064 "reset",
13065 runtime.clone(),
13066 None,
13067 ConsoleEventStore::new(),
13068 Arc::new(AllowAllConsoleVisibilityPolicy),
13069 );
13070 let primed_empty = aggregator.list_identities().await?;
13071 assert!(
13072 primed_empty.is_empty(),
13073 "test precondition: identity cache should be primed empty before late spawn"
13074 );
13075
13076 runtime
13077 .handle()
13078 .spawn_spec(SpawnMemberSpec::from_wire(
13079 "worker".to_string(),
13080 "agent-reset".to_string(),
13081 Some("You are agent-reset.".into()),
13082 None,
13083 None,
13084 ))
13085 .await?;
13086
13087 let response = Box::pin(handle_console_aggregator_rpc(
13088 Some(aggregator),
13089 rpc_request("mobkit/reset_all"),
13090 true,
13091 false,
13092 None,
13093 None,
13094 ))
13095 .await;
13096
13097 assert_eq!(response["result"], Value::Null);
13098 assert_eq!(
13099 response["error"]["data"]["kind"],
13100 json!("unsupported_reset_all_surface")
13101 );
13102 assert!(
13103 runtime
13104 .handle()
13105 .get_member(&meerkat_mob::ids::AgentIdentity::from("agent-reset"))
13106 .await
13107 .ok()
13108 .flatten()
13109 .is_some(),
13110 "aggregator reset_all must not retire live members while reporting unsupported"
13111 );
13112 let _ = runtime.handle().stop().await;
13113 Ok(())
13114 }
13115
13116 #[test]
13117 fn timeline_stream_cursor_filter_uses_numeric_console_sequence() {
13118 assert!(cursor_is_after(
13119 &ConsoleCursor::from("console:10"),
13120 &ConsoleCursor::from("console:9")
13121 ));
13122 assert!(!cursor_is_after(
13123 &ConsoleCursor::from("console:9"),
13124 &ConsoleCursor::from("console:10")
13125 ));
13126 }
13127
13128 #[test]
13129 fn console_live_snapshot_dedupes_repeated_delegate_identities() {
13130 let mut members = vec![
13131 ConsoleMember {
13132 agent_identity: "incident-commander".to_string(),
13133 role: "commander".to_string(),
13134 state: "active".to_string(),
13135 model_capabilities: Default::default(),
13136 runtime_mode: None,
13137 session_id: None,
13138 wired_to: Vec::new(),
13139 labels: BTreeMap::new(),
13140 progress: None,
13141 },
13142 ConsoleMember {
13143 agent_identity: "qa-child".to_string(),
13144 role: "delegate".to_string(),
13145 state: "active".to_string(),
13146 model_capabilities: Default::default(),
13147 runtime_mode: None,
13148 session_id: Some("first".to_string()),
13149 wired_to: vec!["qa-parent".to_string()],
13150 labels: BTreeMap::from([(
13151 "delegate_host_identity".to_string(),
13152 "qa-parent".to_string(),
13153 )]),
13154 progress: None,
13155 },
13156 ConsoleMember {
13157 agent_identity: "qa-child".to_string(),
13158 role: "delegate".to_string(),
13159 state: "active".to_string(),
13160 model_capabilities: Default::default(),
13161 runtime_mode: None,
13162 session_id: Some("second".to_string()),
13163 wired_to: vec!["qa-parent".to_string()],
13164 labels: BTreeMap::from([(
13165 "delegate_host_identity".to_string(),
13166 "qa-parent".to_string(),
13167 )]),
13168 progress: None,
13169 },
13170 ];
13171
13172 dedupe_console_members_by_identity(&mut members);
13173
13174 assert_eq!(
13175 members
13176 .iter()
13177 .map(|member| member.agent_identity.as_str())
13178 .collect::<Vec<_>>(),
13179 vec!["incident-commander", "qa-child"]
13180 );
13181 assert_eq!(members[1].session_id.as_deref(), Some("first"));
13182 }
13183
13184 #[test]
13185 fn console_visibility_policy_hides_implicit_delegate_members_from_snapshot() {
13186 let mut snapshot = ConsoleLiveSnapshot::new(
13187 Some("runtime".to_string()),
13188 true,
13189 vec!["incident-commander".to_string(), "qa-child".to_string()],
13190 vec![
13191 ConsoleAgentLiveSnapshot {
13192 agent_id: "incident-commander".to_string(),
13193 member_id: "incident-commander".to_string(),
13194 label: "Incident Commander".to_string(),
13195 kind: "meerkat".to_string(),
13196 identity: Some("incident-commander".to_string()),
13197 role: Some("commander".to_string()),
13198 state: Some("active".to_string()),
13199 session_id: None,
13200 model_capabilities: Default::default(),
13201 response_phase: None,
13202 watched: None,
13203 alert_level: None,
13204 degraded: None,
13205 degraded_reason: None,
13206 },
13207 ConsoleAgentLiveSnapshot {
13208 agent_id: "qa-child".to_string(),
13209 member_id: "qa-child".to_string(),
13210 label: "QA Child".to_string(),
13211 kind: "meerkat".to_string(),
13212 identity: Some("qa-child".to_string()),
13213 role: Some("delegate".to_string()),
13214 state: Some("active".to_string()),
13215 session_id: Some("delegate-session".to_string()),
13216 model_capabilities: Default::default(),
13217 response_phase: None,
13218 watched: None,
13219 alert_level: None,
13220 degraded: None,
13221 degraded_reason: None,
13222 },
13223 ],
13224 vec![
13225 ConsoleMember {
13226 agent_identity: "incident-commander".to_string(),
13227 role: "commander".to_string(),
13228 state: "active".to_string(),
13229 model_capabilities: Default::default(),
13230 runtime_mode: None,
13231 session_id: None,
13232 wired_to: Vec::new(),
13233 labels: BTreeMap::new(),
13234 progress: None,
13235 },
13236 ConsoleMember {
13237 agent_identity: "qa-child".to_string(),
13238 role: "delegate".to_string(),
13239 state: "active".to_string(),
13240 model_capabilities: Default::default(),
13241 runtime_mode: None,
13242 session_id: Some("delegate-session".to_string()),
13243 wired_to: vec!["qa-parent".to_string()],
13244 labels: BTreeMap::from([(
13245 "source_mob_id".to_string(),
13246 "implicit-qa-mob".to_string(),
13247 )]),
13248 progress: None,
13249 },
13250 ],
13251 true,
13252 );
13253
13254 apply_console_visibility_policy(
13255 &mut snapshot,
13256 &HideImplicitDelegateMembersConsoleVisibilityPolicy,
13257 );
13258
13259 assert_eq!(
13260 snapshot
13261 .members
13262 .iter()
13263 .map(|member| member.agent_identity.as_str())
13264 .collect::<Vec<_>>(),
13265 vec!["incident-commander"]
13266 );
13267 assert_eq!(
13268 snapshot
13269 .agents
13270 .iter()
13271 .map(|agent| agent.agent_id.as_str())
13272 .collect::<Vec<_>>(),
13273 vec!["incident-commander"]
13274 );
13275 assert_eq!(snapshot.loaded_modules, vec!["incident-commander"]);
13276 }
13277
13278 #[tokio::test]
13279 async fn live_snapshot_member_projection_uses_roster_profile_capabilities()
13280 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13281 let temp_dir = tempfile::tempdir()?;
13282 let session_path = temp_dir.path().join("sessions");
13283 std::fs::create_dir_all(&session_path)?;
13284 let factory = AgentFactory::new(&session_path).comms(true);
13285 let session_service = Arc::new(build_ephemeral_service(factory, Config::default(), 16));
13286 let definition = MobDefinition::from_toml(
13287 r#"
13288[mob]
13289id = "console-snapshot-test"
13290
13291[profiles.worker]
13292model = "gpt-5.5"
13293
13294[profiles.worker.tools]
13295comms = true
13296"#,
13297 )?;
13298 let expected = model_capabilities_for_role(&definition, "worker");
13299 let runtime = MobRuntime::bootstrap(
13300 MobBootstrapSpec::new(definition, MobStorage::in_memory(), session_service)
13301 .with_options(MobBootstrapOptions {
13302 allow_ephemeral_sessions: true,
13303 notify_orchestrator_on_resume: true,
13304 default_llm_client: Some(Arc::new(TestClient::default())),
13305 }),
13306 )
13307 .await?;
13308 runtime
13309 .handle()
13310 .spawn_spec(SpawnMemberSpec::from_wire(
13311 "worker".to_string(),
13312 crate::member_comms_id::mob_member_id_str("worker:one").into_owned(),
13315 Some("You are worker one.".into()),
13316 None,
13317 None,
13318 ))
13319 .await?;
13320
13321 let empty_read_model = ConsoleSnapshotReadModelState::default();
13322 let (members, session_owner_by_id) = project_console_members_from_handle(
13323 &runtime.handle(),
13324 None,
13325 None,
13326 &empty_read_model,
13327 &BTreeMap::new(),
13328 )
13329 .await;
13330
13331 assert_eq!(members.len(), 1);
13332 assert_eq!(members[0].model_capabilities, expected);
13333 assert_eq!(members[0].session_id, None);
13334 assert!(session_owner_by_id.is_empty());
13335
13336 let refreshed_read_model = collect_console_snapshot_read_model(&runtime).await;
13337 let (members, session_owner_by_id) = project_console_members_from_handle(
13338 &runtime.handle(),
13339 None,
13340 None,
13341 &refreshed_read_model,
13342 &BTreeMap::new(),
13343 )
13344 .await;
13345 assert_eq!(
13346 members[0].session_id.as_ref(),
13347 session_owner_by_id.keys().next()
13348 );
13349
13350 assert_eq!(
13356 refreshed_read_model.primary_members.len(),
13357 members.len(),
13358 "primary_members cache should hold the same members as live projection"
13359 );
13360 assert_eq!(
13361 refreshed_read_model.primary_members[0].agent_identity,
13362 members[0].agent_identity
13363 );
13364 assert_eq!(
13365 refreshed_read_model.primary_members[0].session_id,
13366 members[0].session_id
13367 );
13368 Ok(())
13369 }
13370
13371 #[tokio::test]
13372 async fn fresh_timeline_snapshot_reads_tail_without_full_log_replay()
13373 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13374 let aggregator = MobKitConsoleAggregator::in_memory();
13375 for idx in 0..250_000 {
13376 aggregator
13377 .store()
13378 .append_if_absent(NewConsoleFrame {
13379 id: None,
13380 dedupe_key: format!("event-{idx}"),
13381 timestamp_ms: idx,
13382 runtime_key: "runtime-a".to_string(),
13383 identity: "agent-a".to_string(),
13384 conversation_id: Some("agent-a".to_string()),
13385 session_id: None,
13386 kind: "text_delta".to_string(),
13387 status: ConsoleFrameStatus::Completed,
13388 payload: json!({ "delta": idx }),
13389 source: ConsoleFrameSource {
13390 kind: ConsoleFrameSourceKind::ConsoleEvent,
13391 source_cursor: None,
13392 },
13393 source_event_id: Some(format!("event-{idx}")),
13394 interaction_id: None,
13395 turn_id: None,
13396 run_id: None,
13397 parent_frame_id: None,
13398 caused_by_frame_id: None,
13399 })
13400 .await?;
13401 }
13402
13403 let (frames, cursor) = query_timeline_snapshot(
13404 &aggregator,
13405 ConsoleTimelineWindowQuery {
13406 identity: Some("agent-a".to_string()),
13407 after: None,
13408 limit: 200,
13409 ..ConsoleTimelineWindowQuery::default()
13410 },
13411 )
13412 .await?;
13413
13414 assert!(!frames.is_empty());
13415 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(250_000));
13416 assert_eq!(
13417 frames.last().and_then(|frame| frame.cursor.seq()),
13418 Some(250_000)
13419 );
13420 Ok(())
13421 }
13422
13423 #[tokio::test]
13424 async fn fresh_timeline_snapshot_keeps_sparse_identity_frames()
13425 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13426 let aggregator = MobKitConsoleAggregator::in_memory();
13427 aggregator
13428 .store()
13429 .append_if_absent(NewConsoleFrame {
13430 id: None,
13431 dedupe_key: "sparse-event".to_string(),
13432 timestamp_ms: 1,
13433 runtime_key: "runtime-a".to_string(),
13434 identity: "sparse-agent".to_string(),
13435 conversation_id: Some("sparse-agent".to_string()),
13436 session_id: None,
13437 kind: "text_complete".to_string(),
13438 status: ConsoleFrameStatus::Completed,
13439 payload: json!({ "text": "still visible" }),
13440 source: ConsoleFrameSource {
13441 kind: ConsoleFrameSourceKind::ConsoleEvent,
13442 source_cursor: None,
13443 },
13444 source_event_id: Some("sparse-event".to_string()),
13445 interaction_id: None,
13446 turn_id: None,
13447 run_id: None,
13448 parent_frame_id: None,
13449 caused_by_frame_id: None,
13450 })
13451 .await?;
13452 for idx in 0..25_000 {
13453 aggregator
13454 .store()
13455 .append_if_absent(NewConsoleFrame {
13456 id: None,
13457 dedupe_key: format!("other-event-{idx}"),
13458 timestamp_ms: idx + 2,
13459 runtime_key: "runtime-a".to_string(),
13460 identity: "busy-agent".to_string(),
13461 conversation_id: Some("busy-agent".to_string()),
13462 session_id: None,
13463 kind: "text_delta".to_string(),
13464 status: ConsoleFrameStatus::Completed,
13465 payload: json!({ "delta": idx }),
13466 source: ConsoleFrameSource {
13467 kind: ConsoleFrameSourceKind::ConsoleEvent,
13468 source_cursor: None,
13469 },
13470 source_event_id: Some(format!("other-event-{idx}")),
13471 interaction_id: None,
13472 turn_id: None,
13473 run_id: None,
13474 parent_frame_id: None,
13475 caused_by_frame_id: None,
13476 })
13477 .await?;
13478 }
13479
13480 let (frames, cursor) = query_timeline_snapshot(
13481 &aggregator,
13482 ConsoleTimelineWindowQuery {
13483 identity: Some("sparse-agent".to_string()),
13484 after: None,
13485 limit: 200,
13486 ..ConsoleTimelineWindowQuery::default()
13487 },
13488 )
13489 .await?;
13490
13491 assert_eq!(frames.len(), 1);
13492 assert_eq!(frames[0].identity, "sparse-agent");
13493 assert_eq!(frames[0].payload["text"], json!("still visible"));
13494 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(1));
13495 Ok(())
13496 }
13497
13498 #[tokio::test]
13499 async fn fresh_identity_snapshot_keeps_user_input_anchor_before_noisy_tail()
13500 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13501 let aggregator = MobKitConsoleAggregator::in_memory();
13502 aggregator
13503 .store()
13504 .append_if_absent(NewConsoleFrame {
13505 id: None,
13506 dedupe_key: "worker-kickoff".to_string(),
13507 timestamp_ms: 1,
13508 runtime_key: "runtime-a".to_string(),
13509 identity: "review-worker-a".to_string(),
13510 conversation_id: Some("review-worker-a".to_string()),
13511 session_id: None,
13512 kind: "user_input".to_string(),
13513 status: ConsoleFrameStatus::Delivered,
13514 payload: json!({
13515 "content": [
13516 {
13517 "type": "text",
13518 "text": "Console chat smoke: review this initiative"
13519 }
13520 ]
13521 }),
13522 source: ConsoleFrameSource {
13523 kind: ConsoleFrameSourceKind::Synthetic,
13524 source_cursor: None,
13525 },
13526 source_event_id: Some("worker-kickoff".to_string()),
13527 interaction_id: Some("kickoff-1".to_string()),
13528 turn_id: None,
13529 run_id: None,
13530 parent_frame_id: None,
13531 caused_by_frame_id: None,
13532 })
13533 .await?;
13534 for idx in 0..1_500 {
13535 aggregator
13536 .store()
13537 .append_if_absent(NewConsoleFrame {
13538 id: None,
13539 dedupe_key: format!("worker-delta-{idx}"),
13540 timestamp_ms: idx + 2,
13541 runtime_key: "runtime-a".to_string(),
13542 identity: "review-worker-a".to_string(),
13543 conversation_id: Some("review-worker-a".to_string()),
13544 session_id: None,
13545 kind: "reasoning_delta".to_string(),
13546 status: ConsoleFrameStatus::Delivered,
13547 payload: json!({ "delta": idx }),
13548 source: ConsoleFrameSource {
13549 kind: ConsoleFrameSourceKind::ConsoleEvent,
13550 source_cursor: None,
13551 },
13552 source_event_id: Some(format!("worker-delta-{idx}")),
13553 interaction_id: Some("kickoff-1".to_string()),
13554 turn_id: None,
13555 run_id: None,
13556 parent_frame_id: None,
13557 caused_by_frame_id: None,
13558 })
13559 .await?;
13560 }
13561
13562 let (frames, cursor) = query_timeline_snapshot(
13563 &aggregator,
13564 ConsoleTimelineWindowQuery {
13565 identity: Some("review-worker-a".to_string()),
13566 after: None,
13567 limit: 200,
13568 ..ConsoleTimelineWindowQuery::default()
13569 },
13570 )
13571 .await?;
13572
13573 assert!(
13574 frames.iter().any(|frame| {
13575 frame.kind == "user_input"
13576 && frame.payload.to_string().contains("Console chat smoke")
13577 }),
13578 "identity chat snapshot must keep the worker kickoff prompt before a noisy tail: {frames:#?}",
13579 );
13580 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(1_501));
13581 assert_eq!(
13582 frames.last().and_then(|frame| frame.cursor.seq()),
13583 Some(1_501)
13584 );
13585 Ok(())
13586 }
13587
13588 #[tokio::test]
13589 async fn timeline_snapshot_drains_since_backlog_across_store_pages()
13590 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13591 let aggregator = MobKitConsoleAggregator::in_memory();
13592 for idx in 0..2_500 {
13593 aggregator
13594 .store()
13595 .append_if_absent(NewConsoleFrame {
13596 id: None,
13597 dedupe_key: format!("clamp-event-{idx}"),
13598 timestamp_ms: idx,
13599 runtime_key: "runtime-a".to_string(),
13600 identity: "agent-a".to_string(),
13601 conversation_id: Some("agent-a".to_string()),
13602 session_id: None,
13603 kind: "text_delta".to_string(),
13604 status: ConsoleFrameStatus::Completed,
13605 payload: json!({ "delta": idx }),
13606 source: ConsoleFrameSource {
13607 kind: ConsoleFrameSourceKind::ConsoleEvent,
13608 source_cursor: None,
13609 },
13610 source_event_id: Some(format!("clamp-event-{idx}")),
13611 interaction_id: None,
13612 turn_id: None,
13613 run_id: None,
13614 parent_frame_id: None,
13615 caused_by_frame_id: None,
13616 })
13617 .await?;
13618 }
13619
13620 let (frames, cursor) = query_timeline_snapshot(
13621 &aggregator,
13622 ConsoleTimelineWindowQuery {
13623 identity: Some("agent-a".to_string()),
13624 after: Some(ConsoleCursor::from("console:100")),
13625 limit: 5_000,
13626 ..ConsoleTimelineWindowQuery::default()
13627 },
13628 )
13629 .await?;
13630
13631 assert_eq!(frames.len(), 2_400);
13632 assert_eq!(
13633 frames.first().and_then(|frame| frame.cursor.seq()),
13634 Some(101)
13635 );
13636 assert_eq!(
13637 frames.last().and_then(|frame| frame.cursor.seq()),
13638 Some(2_500)
13639 );
13640 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(2_500));
13641 Ok(())
13642 }
13643
13644 #[tokio::test]
13645 async fn timeline_snapshot_drains_since_backlog_beyond_old_page_budget()
13646 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13647 let aggregator = MobKitConsoleAggregator::in_memory();
13648 for idx in 1..=150 {
13649 aggregator
13650 .store()
13651 .append_if_absent(NewConsoleFrame {
13652 id: None,
13653 dedupe_key: format!("deep-backlog-event-{idx}"),
13654 timestamp_ms: idx,
13655 runtime_key: "runtime-a".to_string(),
13656 identity: "agent-a".to_string(),
13657 conversation_id: Some("agent-a".to_string()),
13658 session_id: None,
13659 kind: "text_delta".to_string(),
13660 status: ConsoleFrameStatus::Completed,
13661 payload: json!({ "delta": idx }),
13662 source: ConsoleFrameSource {
13663 kind: ConsoleFrameSourceKind::ConsoleEvent,
13664 source_cursor: None,
13665 },
13666 source_event_id: Some(format!("deep-backlog-event-{idx}")),
13667 interaction_id: None,
13668 turn_id: None,
13669 run_id: None,
13670 parent_frame_id: None,
13671 caused_by_frame_id: None,
13672 })
13673 .await?;
13674 }
13675
13676 let (frames, cursor) = query_timeline_snapshot(
13677 &aggregator,
13678 ConsoleTimelineWindowQuery {
13679 identity: Some("agent-a".to_string()),
13680 after: Some(ConsoleCursor::from_seq(1)),
13681 limit: 1,
13682 ..ConsoleTimelineWindowQuery::default()
13683 },
13684 )
13685 .await?;
13686
13687 assert_eq!(frames.len(), 149);
13688 assert_eq!(frames.first().and_then(|frame| frame.cursor.seq()), Some(2));
13689 assert_eq!(
13690 frames.last().and_then(|frame| frame.cursor.seq()),
13691 Some(150)
13692 );
13693 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(150));
13694 Ok(())
13695 }
13696
13697 #[tokio::test]
13698 async fn timeline_snapshot_rejects_after_cursor_beyond_store_frontier()
13699 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13700 let aggregator = MobKitConsoleAggregator::in_memory();
13701 aggregator
13702 .store()
13703 .append_if_absent(NewConsoleFrame {
13704 id: None,
13705 dedupe_key: "stale-frontier-event".to_string(),
13706 timestamp_ms: 1,
13707 runtime_key: "runtime-a".to_string(),
13708 identity: "agent-a".to_string(),
13709 conversation_id: Some("agent-a".to_string()),
13710 session_id: None,
13711 kind: "text_delta".to_string(),
13712 status: ConsoleFrameStatus::Completed,
13713 payload: json!({ "delta": 1 }),
13714 source: ConsoleFrameSource {
13715 kind: ConsoleFrameSourceKind::ConsoleEvent,
13716 source_cursor: None,
13717 },
13718 source_event_id: Some("stale-frontier-event".to_string()),
13719 interaction_id: None,
13720 turn_id: None,
13721 run_id: None,
13722 parent_frame_id: None,
13723 caused_by_frame_id: None,
13724 })
13725 .await?;
13726
13727 let err = match query_timeline_snapshot(
13728 &aggregator,
13729 ConsoleTimelineWindowQuery {
13730 after: Some(ConsoleCursor::from("console:99")),
13731 limit: 200,
13732 ..ConsoleTimelineWindowQuery::default()
13733 },
13734 )
13735 .await
13736 {
13737 Ok(_) => {
13738 return Err(
13739 std::io::Error::other("future cursor must be replay-unavailable").into(),
13740 );
13741 }
13742 Err(err) => err,
13743 };
13744
13745 assert!(
13746 err.to_string()
13747 .contains("beyond the current store frontier"),
13748 "unexpected error: {err}"
13749 );
13750 Ok(())
13751 }
13752
13753 #[tokio::test]
13754 async fn timeline_snapshot_rejects_after_cursor_beyond_empty_store_frontier()
13755 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13756 let aggregator = MobKitConsoleAggregator::in_memory();
13757
13758 let err = match query_timeline_snapshot(
13759 &aggregator,
13760 ConsoleTimelineWindowQuery {
13761 after: Some(ConsoleCursor::from("console:99")),
13762 limit: 200,
13763 ..ConsoleTimelineWindowQuery::default()
13764 },
13765 )
13766 .await
13767 {
13768 Ok(_) => {
13769 return Err(std::io::Error::other("empty store future cursor must fail").into());
13770 }
13771 Err(err) => err,
13772 };
13773
13774 assert!(
13775 err.to_string()
13776 .contains("beyond the current store frontier"),
13777 "unexpected error: {err}"
13778 );
13779 Ok(())
13780 }
13781
13782 #[test]
13783 fn timeline_query_prefers_last_event_id_over_url_after_cursor() {
13784 let query = timeline_query_from_http(
13785 ConsoleTimelineHttpQuery {
13786 identity: None,
13787 conversation_id: None,
13788 after: Some("console:100".to_string()),
13789 before: None,
13790 mode: None,
13791 limit: None,
13792 },
13793 Some("console:150".to_string()),
13794 );
13795
13796 assert_eq!(query.after.as_ref().and_then(ConsoleCursor::seq), Some(150));
13797 }
13798
13799 #[test]
13800 fn console_timeline_replay_unavailable_rpc_uses_dedicated_error_code() {
13801 let response = console_timeline_replay_unavailable_response(
13802 json!("rid"),
13803 std::io::Error::other("timeline replay cursor is beyond the current store frontier")
13804 .into(),
13805 Some(&ConsoleCursor::from_seq(100)),
13806 Some(ConsoleCursor::from_seq(42)),
13807 );
13808
13809 assert_eq!(response["error"]["code"], json!(-32013));
13810 assert_eq!(
13811 response["error"]["data"],
13812 json!({
13813 "error": "replay_unavailable",
13814 "stream": "timeline",
13815 "requested_cursor": "console:100",
13816 "latest_cursor": "console:42",
13817 })
13818 );
13819 }
13820
13821 #[tokio::test]
13822 async fn multipart_blob_upload_stores_one_file() -> Result<(), Box<dyn std::error::Error>> {
13823 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13824 let mut files = BTreeMap::new();
13825 files.insert(
13826 "upload-1".to_string(),
13827 MultipartImageUpload {
13828 media_type: "image/png".to_string(),
13829 bytes: Bytes::from_static(b"png-data"),
13830 },
13831 );
13832 let result = externalize_single_image_upload(
13833 &json!({
13834 "upload": {
13835 "type": "image_upload",
13836 "upload_id": "upload-1",
13837 "media_type": "image/png"
13838 }
13839 }),
13840 files,
13841 store.clone(),
13842 )
13843 .await
13844 .map_err(std::io::Error::other)?;
13845
13846 assert_eq!(result["media_type"], json!("image/png"));
13847 assert_eq!(result["size"], json!(8));
13848 let Some(blob_id) = result["blob_id"].as_str() else {
13849 return Err(std::io::Error::other("blob id").into());
13850 };
13851 let payload = store
13852 .get_bytes(&meerkat_core::BlobId::from(blob_id))
13853 .await?;
13854 assert_eq!(payload.data.as_ref(), b"png-data");
13855 Ok(())
13856 }
13857
13858 #[tokio::test]
13859 async fn multipart_blob_upload_accepts_part_name_alias()
13860 -> Result<(), Box<dyn std::error::Error>> {
13861 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13862 let mut files = BTreeMap::new();
13863 files.insert(
13864 "image-field".to_string(),
13865 MultipartImageUpload {
13866 media_type: "image/png".to_string(),
13867 bytes: Bytes::from_static(b"png-data"),
13868 },
13869 );
13870 let result = externalize_single_image_upload(
13871 &json!({
13872 "upload": {
13873 "type": "image_upload",
13874 "part_name": "image-field",
13875 "media_type": "image/png"
13876 }
13877 }),
13878 files,
13879 store,
13880 )
13881 .await
13882 .map_err(std::io::Error::other)?;
13883
13884 assert_eq!(result["media_type"], json!("image/png"));
13885 assert!(
13886 result["blob_id"]
13887 .as_str()
13888 .is_some_and(|value| value.starts_with("sha256:"))
13889 );
13890 Ok(())
13891 }
13892
13893 #[tokio::test]
13894 async fn multipart_blob_upload_rejects_media_mismatch() -> Result<(), Box<dyn std::error::Error>>
13895 {
13896 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13897 let mut files = BTreeMap::new();
13898 files.insert(
13899 "upload-1".to_string(),
13900 MultipartImageUpload {
13901 media_type: "image/jpeg".to_string(),
13902 bytes: Bytes::from_static(b"jpeg-data"),
13903 },
13904 );
13905 let err = match externalize_single_image_upload(
13906 &json!({
13907 "upload": {
13908 "type": "image_upload",
13909 "upload_id": "upload-1",
13910 "media_type": "image/png"
13911 }
13912 }),
13913 files,
13914 store,
13915 )
13916 .await
13917 {
13918 Ok(_) => return Err(std::io::Error::other("media mismatch").into()),
13919 Err(err) => err,
13920 };
13921 assert!(
13922 err.contains("media type mismatch"),
13923 "unexpected error: {err}"
13924 );
13925 Ok(())
13926 }
13927
13928 #[tokio::test]
13929 async fn multipart_blob_upload_rejects_extra_file() -> Result<(), Box<dyn std::error::Error>> {
13930 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13931 let mut files = BTreeMap::new();
13932 for id in ["upload-1", "upload-2"] {
13933 files.insert(
13934 id.to_string(),
13935 MultipartImageUpload {
13936 media_type: "image/png".to_string(),
13937 bytes: Bytes::from_static(b"png"),
13938 },
13939 );
13940 }
13941 let err = match externalize_single_image_upload(
13942 &json!({
13943 "upload": {
13944 "type": "image_upload",
13945 "upload_id": "upload-1",
13946 "media_type": "image/png"
13947 }
13948 }),
13949 files,
13950 store,
13951 )
13952 .await
13953 {
13954 Ok(_) => return Err(std::io::Error::other("one file only").into()),
13955 Err(err) => err,
13956 };
13957 assert!(
13958 err.contains("exactly one file part"),
13959 "unexpected error: {err}"
13960 );
13961 Ok(())
13962 }
13963
13964 #[tokio::test]
13965 async fn multipart_send_replaces_placeholders_and_removes_shadow_message()
13966 -> Result<(), Box<dyn std::error::Error>> {
13967 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
13968 let mut files = BTreeMap::new();
13969 files.insert(
13970 "upload-1".to_string(),
13971 MultipartImageUpload {
13972 media_type: "image/webp".to_string(),
13973 bytes: Bytes::from_static(b"webp-data"),
13974 },
13975 );
13976 let mut params = json!({
13977 "member_id": "artist",
13978 "message": "stale shadow text",
13979 "content": [
13980 { "type": "text", "text": "describe" },
13981 {
13982 "type": "image_upload",
13983 "upload_id": "upload-1",
13984 "media_type": "image/webp"
13985 }
13986 ]
13987 });
13988 externalize_image_upload_placeholders(&mut params, files, store)
13989 .await
13990 .map_err(std::io::Error::other)?;
13991
13992 assert!(params.get("message").is_none());
13993 assert_eq!(params["content"][1]["type"], json!("image"));
13994 assert_eq!(params["content"][1]["source"], json!("blob"));
13995 assert_eq!(params["content"][1]["media_type"], json!("image/webp"));
13996 assert!(
13997 params["content"][1]["blob_id"]
13998 .as_str()
13999 .is_some_and(|value| value.starts_with("sha256:"))
14000 );
14001 Ok(())
14002 }
14003
14004 #[tokio::test]
14005 async fn multipart_send_accepts_part_name_placeholder() -> Result<(), Box<dyn std::error::Error>>
14006 {
14007 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14008 let mut files = BTreeMap::new();
14009 files.insert(
14010 "image-field".to_string(),
14011 MultipartImageUpload {
14012 media_type: "image/png".to_string(),
14013 bytes: Bytes::from_static(b"png-data"),
14014 },
14015 );
14016 let mut params = json!({
14017 "member_id": "analyst",
14018 "content": [
14019 { "type": "text", "text": "describe" },
14020 {
14021 "type": "image_upload",
14022 "part_name": "image-field",
14023 "media_type": "image/png"
14024 }
14025 ]
14026 });
14027
14028 externalize_image_upload_placeholders(&mut params, files, store)
14029 .await
14030 .map_err(std::io::Error::other)?;
14031
14032 assert_eq!(params["content"][1]["type"], json!("image"));
14033 assert_eq!(params["content"][1]["source"], json!("blob"));
14034 assert_eq!(params["content"][1]["media_type"], json!("image/png"));
14035 Ok(())
14036 }
14037
14038 #[tokio::test]
14039 async fn multipart_send_rejects_placeholder_without_file()
14040 -> Result<(), Box<dyn std::error::Error>> {
14041 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14042 let mut params = json!({
14043 "content": [{
14044 "type": "image_upload",
14045 "upload_id": "missing",
14046 "media_type": "image/png"
14047 }]
14048 });
14049 let err = match externalize_image_upload_placeholders(&mut params, BTreeMap::new(), store)
14050 .await
14051 {
14052 Ok(()) => return Err(std::io::Error::other("missing file").into()),
14053 Err(err) => err,
14054 };
14055 assert!(err.contains("missing file part"), "unexpected error: {err}");
14056 Ok(())
14057 }
14058
14059 #[test]
14060 fn generated_runtime_ids_do_not_match_sibling_colon_identities() {
14061 assert!(!member_id_matches_durable_identity(
14062 "rt:review:singleton:0",
14063 "review:singleton"
14064 ));
14065 assert!(!member_id_matches_durable_identity(
14066 "review:singleton:gen1",
14067 "review:singleton"
14068 ));
14069 assert!(!member_id_matches_durable_identity(
14070 "review:singleton:1",
14071 "review:singleton"
14072 ));
14073 assert!(!member_id_matches_durable_identity(
14074 "rt:review:singleton:qa:0",
14075 "review:singleton"
14076 ));
14077 assert!(!member_id_matches_durable_identity(
14078 "review:singleton:qa",
14079 "review:singleton"
14080 ));
14081 }
14082}