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, AgentRuntimeId, FenceToken, 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<Arc<dyn crate::memory::capabilities::MemoryPanelStore>>,
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 pub(crate) job_health_projection: Option<Arc<std::sync::RwLock<Option<serde_json::Value>>>>,
127}
128
129#[derive(Debug, Clone)]
130struct ConsoleHttpAuthContext {
131 principal: Option<String>,
132 access_view: Option<AccessView>,
134}
135
136#[derive(Clone, Default)]
137pub(crate) struct ConsoleSnapshotReadModel {
138 inner: Arc<tokio::sync::RwLock<ConsoleSnapshotReadModelState>>,
139 refresh_lock: Arc<tokio::sync::Mutex<()>>,
145 primed: Arc<std::sync::atomic::AtomicBool>,
149}
150
151#[derive(Clone, Default)]
152struct ConsoleSnapshotReadModelState {
153 running: Option<bool>,
154 session_id_by_identity: BTreeMap<String, String>,
155 session_owner_by_id: BTreeMap<String, String>,
156 primary_members: Vec<ConsoleMember>,
161 delegate_member_groups: Vec<Vec<ConsoleMember>>,
166}
167
168impl ConsoleSnapshotReadModel {
169 async fn snapshot(&self, runtime: &MobRuntime) -> ConsoleSnapshotReadModelState {
176 if !self.primed.load(std::sync::atomic::Ordering::Acquire) {
177 Box::pin(self.prime_now(runtime)).await;
178 }
179 self.inner.read().await.clone()
180 }
181
182 async fn prime_now(&self, runtime: &MobRuntime) {
202 if self.primed.load(std::sync::atomic::Ordering::Acquire) {
203 return;
204 }
205 let _guard = self.refresh_lock.clone().lock_owned().await;
206 if self.primed.load(std::sync::atomic::Ordering::Acquire) {
207 return;
208 }
209 let refreshed = Box::pin(collect_console_snapshot_read_model(runtime)).await;
210 *self.inner.write().await = refreshed;
211 self.primed
212 .store(true, std::sync::atomic::Ordering::Release);
213 }
217
218 fn refresh_soon(&self, runtime: MobRuntime) {
225 let Ok(runtime_handle) = tokio::runtime::Handle::try_current() else {
226 return;
227 };
228 let Ok(guard) = self.refresh_lock.clone().try_lock_owned() else {
229 return;
230 };
231 let inner = Arc::clone(&self.inner);
232 let primed = Arc::clone(&self.primed);
233 runtime_handle.spawn(async move {
234 let _guard = guard;
235 let refreshed = Box::pin(collect_console_snapshot_read_model(&runtime)).await;
236 *inner.write().await = refreshed;
237 primed.store(true, std::sync::atomic::Ordering::Release);
238 });
242 }
243}
244
245const CONSOLE_FRONTEND_INDEX_HTML: &str = include_str!("../console-dist/index.html");
246const CONSOLE_FRONTEND_APP_JS: &str = include_str!("../console-dist/console-app.js");
247const CONSOLE_FRONTEND_APP_CSS: &str = include_str!("../console-dist/console-app.css");
248const MAX_MULTIPART_IMAGE_BYTES: usize = 25 * 1024 * 1024;
249const MAX_MULTIPART_IMAGES: usize = 4;
250const MAX_MULTIPART_BODY_BYTES: usize =
251 (MAX_MULTIPART_IMAGE_BYTES * MAX_MULTIPART_IMAGES) + 1024 * 1024;
252
253pub fn console_json_router(decisions: RuntimeDecisionState) -> Router {
254 console_json_router_with_state(ConsoleJsonState {
255 decisions,
256 runtime: None,
257 module_runtime: None,
258 contact_directory: None,
259 event_log: None,
260 gateway_peer_keys: None,
261 identity_runtime: None,
262 console_events: None,
263 console_aggregator: None,
264 mob_events: None,
265 metadata_table: None,
266 visibility_policy: Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
267 snapshot_read_model: ConsoleSnapshotReadModel::default(),
268 access: None,
269 memory_panel: None,
270 operator_resolver: None,
271 identity_roster: None,
272 workgraph: None,
273 topology: None,
274 job_health_projection: None,
275 })
276}
277
278pub fn console_json_router_with_aggregator(
279 decisions: RuntimeDecisionState,
280 console_aggregator: MobKitConsoleAggregator,
281) -> Router {
282 console_json_router_with_aggregator_and_access(decisions, console_aggregator, None)
283}
284
285pub fn console_json_router_with_aggregator_and_access(
286 decisions: RuntimeDecisionState,
287 console_aggregator: MobKitConsoleAggregator,
288 access: Option<AccessController>,
289) -> Router {
290 console_json_router_with_state(ConsoleJsonState {
291 decisions,
292 runtime: None,
293 module_runtime: None,
294 contact_directory: None,
295 event_log: None,
296 gateway_peer_keys: None,
297 identity_runtime: None,
298 console_events: None,
299 console_aggregator: Some(console_aggregator),
300 mob_events: None,
301 metadata_table: None,
302 visibility_policy: Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
303 snapshot_read_model: ConsoleSnapshotReadModel::default(),
304 access,
305 memory_panel: None,
306 operator_resolver: None,
307 identity_roster: None,
308 workgraph: None,
309 topology: None,
310 job_health_projection: None,
311 })
312}
313
314pub fn console_json_router_with_runtime(
315 decisions: RuntimeDecisionState,
316 runtime: MobRuntime,
317 contact_directory: Option<ContactDirectory>,
318 event_log: Option<std::sync::Arc<dyn EventLogStore>>,
319) -> Router {
320 console_json_router_with_runtime_and_events(
321 decisions,
322 runtime,
323 None,
324 contact_directory,
325 event_log,
326 None,
327 None,
328 None,
329 None,
330 None,
331 None,
332 )
333}
334
335#[allow(clippy::too_many_arguments)]
336pub(crate) fn console_json_router_with_runtime_and_events(
337 decisions: RuntimeDecisionState,
338 runtime: MobRuntime,
339 module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
340 contact_directory: Option<ContactDirectory>,
341 event_log: Option<std::sync::Arc<dyn EventLogStore>>,
342 gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
343 console_events: Option<ConsoleEventStore>,
344 console_log_store: Option<std::sync::Arc<dyn ConsoleLogStore>>,
345 mob_events: Option<MobEventsStore>,
346 metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
347 identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
348) -> Router {
349 console_json_router_with_runtime_events_and_policy(
350 decisions,
351 runtime,
352 module_runtime,
353 contact_directory,
354 event_log,
355 gateway_peer_keys,
356 console_events,
357 console_log_store,
358 mob_events,
359 metadata_table,
360 identity_runtime,
361 Arc::new(HideImplicitDelegateMembersConsoleVisibilityPolicy),
362 None,
363 None,
364 None,
365 None,
366 None,
367 None,
368 None,
369 )
370}
371
372#[allow(clippy::too_many_arguments)]
373pub(crate) fn console_json_router_with_runtime_events_and_policy(
374 decisions: RuntimeDecisionState,
375 runtime: MobRuntime,
376 module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
377 contact_directory: Option<ContactDirectory>,
378 event_log: Option<std::sync::Arc<dyn EventLogStore>>,
379 gateway_peer_keys: Option<crate::auth::peer_keys::GatewayPeerKeys>,
380 console_events: Option<ConsoleEventStore>,
381 console_log_store: Option<std::sync::Arc<dyn ConsoleLogStore>>,
382 mob_events: Option<MobEventsStore>,
383 metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
384 identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
385 visibility_policy: Arc<dyn ConsoleVisibilityPolicy>,
386 access: Option<AccessController>,
387 memory_panel: Option<Arc<dyn crate::memory::capabilities::MemoryPanelStore>>,
388 operator_resolver: Option<Arc<crate::memory::coordinator::ConsolePrincipalOperatorResolver>>,
389 identity_roster: Option<Arc<crate::identity_first::MutableRosterProvider>>,
390 workgraph: Option<meerkat::WorkGraphService>,
391 topology: Option<crate::topology_control::TopologyRuntimeHandle>,
392 job_health_projection: Option<Arc<std::sync::RwLock<Option<serde_json::Value>>>>,
393) -> Router {
394 let console_aggregator = console_events.clone().map(|events| {
395 if let Some(store) = console_log_store {
396 let aggregator = MobKitConsoleAggregator::new(store);
397 aggregator.register_runtime_handles_with_policy(
398 "default",
399 "",
400 runtime.clone(),
401 identity_runtime.clone(),
402 events,
403 visibility_policy.clone(),
404 );
405 aggregator
406 } else {
407 let aggregator = MobKitConsoleAggregator::in_memory();
408 aggregator.register_runtime_handles_with_policy(
409 "default",
410 "",
411 runtime.clone(),
412 identity_runtime.clone(),
413 events,
414 visibility_policy.clone(),
415 );
416 aggregator
417 }
418 });
419 let snapshot_read_model = ConsoleSnapshotReadModel::default();
420 snapshot_read_model.refresh_soon(runtime.clone());
421 let workgraph = workgraph.or_else(|| runtime.workgraph_service());
424 console_json_router_with_state(ConsoleJsonState {
425 decisions,
426 runtime: Some(runtime),
427 module_runtime,
428 contact_directory,
429 event_log,
430 gateway_peer_keys,
431 identity_runtime,
432 console_events,
433 console_aggregator,
434 mob_events,
435 metadata_table,
436 visibility_policy,
437 snapshot_read_model,
438 access,
439 memory_panel,
440 operator_resolver,
441 identity_roster,
442 workgraph,
443 topology,
444 job_health_projection,
445 })
446}
447
448pub fn console_frontend_router() -> Router {
449 Router::new()
450 .route("/", get(|| async { Redirect::temporary("/console") }))
451 .route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT }))
452 .route("/console", get(console_frontend_index_handler))
453 .route("/console/", get(console_frontend_index_handler))
454 .route(
455 "/console/assets/console-app.js",
456 get(console_frontend_app_js_handler),
457 )
458 .route(
459 "/console/assets/console-app.css",
460 get(console_frontend_app_css_handler),
461 )
462}
463
464fn console_json_router_with_state(state: ConsoleJsonState) -> Router {
465 let router = Router::new()
466 .route("/console/experience", get(console_json_handler))
467 .route("/console/modules", get(console_json_handler))
468 .route("/console/identities", get(console_identities_handler))
469 .route("/console/timeline", get(console_timeline_handler))
470 .route(
471 "/console/timeline/stream",
472 get(console_timeline_stream_handler),
473 )
474 .route(
475 "/console/identity/{identity}/stream",
476 get(console_identity_timeline_stream_handler),
477 )
478 .route("/console/send", post(console_send_handler))
479 .route("/console/rpc", post(console_rpc_handler))
480 .route(
481 "/console/rpc/multipart",
482 post(console_rpc_multipart_handler)
483 .layer(DefaultBodyLimit::max(MAX_MULTIPART_BODY_BYTES)),
484 )
485 .route("/blobs/{blob_id}", get(blob_get_handler));
486 router.with_state(state)
487}
488
489pub async fn console_json_handler(
490 State(state): State<ConsoleJsonState>,
491 headers: HeaderMap,
492 uri: Uri,
493) -> impl IntoResponse {
494 let mut path = uri
495 .path_and_query()
496 .map(|path_and_query| path_and_query.as_str().to_string())
497 .unwrap_or_else(|| uri.path().to_string());
498
499 let already_has_token = path
511 .split_once('?')
512 .map(|(_, q)| form_urlencoded::parse(q.as_bytes()).any(|(key, _)| key == "auth_token"))
513 .unwrap_or(false);
514 if !already_has_token
515 && let Some(bearer) = headers
516 .get(header::AUTHORIZATION)
517 .and_then(|v| v.to_str().ok())
518 .and_then(extract_bearer_token_from_header)
519 {
520 let encoded: String = form_urlencoded::byte_serialize(bearer.as_bytes()).collect();
521 let sep = if path.contains('?') { '&' } else { '?' };
522 path = format!("{path}{sep}auth_token={encoded}");
523 }
524
525 let config_module_ids: Vec<String> = state
526 .decisions
527 .modules
528 .iter()
529 .map(|m| m.id.clone())
530 .collect();
531 let live_snapshot = match &state.runtime {
532 Some(runtime) => {
533 state.snapshot_read_model.refresh_soon(runtime.clone());
534 Some(
535 Box::pin(build_live_snapshot(
536 runtime,
537 &config_module_ids,
538 state.console_events.as_ref(),
539 state.visibility_policy.as_ref(),
540 &state.snapshot_read_model,
541 ))
542 .await,
543 )
544 }
545 None => match &state.console_aggregator {
546 Some(aggregator) => Box::pin(build_aggregator_live_snapshot(
547 aggregator,
548 &config_module_ids,
549 ))
550 .await
551 .ok(),
552 None => None,
553 },
554 }
555 .map(|mut snapshot| {
556 apply_console_visibility_policy(&mut snapshot, state.visibility_policy.as_ref());
557 snapshot
558 });
559
560 let response = handle_console_rest_json_route_with_snapshot_access_memory_and_workgraph(
561 &state.decisions,
562 &ConsoleRestJsonRequest {
563 method: "GET".to_string(),
564 path,
565 auth: None,
566 },
567 live_snapshot.as_ref(),
568 state.access.as_ref(),
569 state.memory_panel.is_some(),
570 state.workgraph.is_some(),
571 );
572 let status = StatusCode::from_u16(response.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
573 (status, Json::<Value>(response.body))
574}
575
576pub async fn console_rpc_handler(
577 State(state): State<ConsoleJsonState>,
578 headers: HeaderMap,
579 uri: Uri,
580 Json(request): Json<Value>,
581) -> impl IntoResponse {
582 let parsed_request = match serde_json::from_value::<JsonRpcRequest>(request) {
584 Ok(req) => req,
585 Err(_) => {
586 return (
587 StatusCode::OK,
588 Json::<Value>(serde_json::json!({
589 "jsonrpc": JSONRPC_VERSION,
590 "id": Value::Null,
591 "error": { "code": -32600, "message": "Invalid Request" }
592 })),
593 );
594 }
595 };
596 let request_method = parsed_request.method.clone();
597
598 let auth_context = match console_request_auth_context(&state, &headers, &uri) {
604 Some(context) => context,
605 None => {
606 return (
607 StatusCode::UNAUTHORIZED,
608 Json::<Value>(serde_json::json!({
609 "jsonrpc": JSONRPC_VERSION,
610 "id": parsed_request.id.unwrap_or(Value::Null),
611 "error": {
612 "code": -32600,
613 "message": "unauthorized: console rpc requires a valid auth token",
614 }
615 })),
616 );
617 }
618 };
619 let is_authenticated = true;
624 let read_only = state.decisions.console.read_only;
625 let Some(runtime) = &state.runtime else {
626 let response_value = Box::pin(handle_console_aggregator_rpc(
627 state.console_aggregator.clone(),
628 parsed_request,
629 is_authenticated,
630 read_only,
631 state.access.as_ref(),
632 auth_context.access_view.as_ref(),
633 ))
634 .await;
635 return (StatusCode::OK, Json::<Value>(response_value));
636 };
637
638 let response_value = Box::pin(handle_console_runtime_rpc_with_visibility(
639 runtime,
640 state.module_runtime.clone(),
641 state.contact_directory.as_ref(),
642 state.gateway_peer_keys.as_ref(),
643 state.console_events.clone(),
644 state.console_aggregator.clone(),
645 state.identity_runtime.clone(),
646 state.metadata_table.clone(),
647 state.mob_events.clone(),
648 state.visibility_policy.as_ref(),
649 parsed_request,
650 is_authenticated,
651 read_only,
652 auth_context.principal.as_deref(),
653 state.access.as_ref(),
654 auth_context.access_view.as_ref(),
655 state.memory_panel.as_deref(),
656 state.identity_roster.clone(),
657 state.workgraph.as_ref(),
658 state.topology.as_ref(),
659 ))
660 .await;
661 let response_value = decorate_console_job_projection(
662 response_value,
663 &request_method,
664 state.job_health_projection.as_ref(),
665 );
666 (StatusCode::OK, Json::<Value>(response_value))
667}
668
669fn decorate_console_job_projection(
670 mut response: Value,
671 method: &str,
672 projection_slot: Option<&Arc<std::sync::RwLock<Option<Value>>>>,
673) -> Value {
674 let projection = projection_slot.and_then(|slot| {
675 slot.read()
676 .unwrap_or_else(std::sync::PoisonError::into_inner)
677 .clone()
678 });
679 let Some(projection) = projection else {
680 return response;
681 };
682 match method {
683 "mobkit/status" | "mobkit/capabilities" => {
684 response["result"]["detached_jobs"] = projection
685 .get("detached_jobs")
686 .cloned()
687 .unwrap_or(Value::Null);
688 }
689 "mobkit/member_status" => {
690 if let Some(session_id) = response["result"]["current_session_id"].as_str()
691 && let Some(session_jobs) = projection
692 .get("by_session")
693 .and_then(|by_session| by_session.get(session_id))
694 {
695 response["result"]["detached_jobs"] = session_jobs.clone();
696 response["result"]["awaiting_detached"] = session_jobs
697 .get("awaiting_detached")
698 .cloned()
699 .unwrap_or(Value::Bool(false));
700 }
701 }
702 _ => {}
703 }
704 response
705}
706
707#[derive(Debug, serde::Deserialize)]
708struct ConsoleTimelineHttpQuery {
709 #[serde(default)]
710 identity: Option<String>,
711 #[serde(default)]
712 conversation_id: Option<String>,
713 #[serde(default)]
714 after: Option<String>,
715 #[serde(default)]
716 before: Option<String>,
717 #[serde(default)]
718 mode: Option<ConsoleTimelineMode>,
719 #[serde(default)]
720 limit: Option<usize>,
721}
722
723async fn console_identities_handler(
724 State(state): State<ConsoleJsonState>,
725 headers: HeaderMap,
726 uri: Uri,
727) -> impl IntoResponse {
728 let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
729 return console_json_error(
730 StatusCode::UNAUTHORIZED,
731 "unauthorized",
732 "console identities require a valid auth token",
733 );
734 };
735 let Some(aggregator) = &state.console_aggregator else {
736 return console_json_error(
737 StatusCode::NOT_FOUND,
738 "unavailable",
739 "console aggregator unavailable",
740 );
741 };
742 let aggregator = aggregator.clone();
743 match Box::pin(aggregator.list_identities()).await {
744 Ok(mut identities) => {
745 retain_visible_identity_records(&mut identities, auth_context.access_view.as_ref());
746 (
747 StatusCode::OK,
748 Json::<Value>(json!({ "identities": identities })),
749 )
750 .into_response()
751 }
752 Err(err) => {
753 tracing::warn!(target: "mobkit::console", error = %err, "console identities request failed");
754 console_json_error(
755 StatusCode::INTERNAL_SERVER_ERROR,
756 "internal_error",
757 "console identities unavailable",
758 )
759 }
760 }
761}
762
763async fn console_timeline_handler(
764 State(state): State<ConsoleJsonState>,
765 headers: HeaderMap,
766 uri: Uri,
767 Query(query): Query<ConsoleTimelineHttpQuery>,
768) -> impl IntoResponse {
769 let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
770 return console_json_error(
771 StatusCode::UNAUTHORIZED,
772 "unauthorized",
773 "console timeline requires a valid auth token",
774 );
775 };
776 let Some(aggregator) = &state.console_aggregator else {
777 return console_json_error(
778 StatusCode::NOT_FOUND,
779 "unavailable",
780 "console aggregator unavailable",
781 );
782 };
783 if let Some(runtime) = &state.runtime
787 && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
788 {
789 prime_access_cache_from_runtime(runtime, controller).await;
790 }
791 let timeline_query = timeline_query_from_http(query, None);
792 match Box::pin(aggregator.query_timeline_windowed(timeline_query)).await {
793 Ok(mut page) => {
794 if let Some(view) = auth_context.access_view.as_ref() {
795 page.frames
796 .retain(|frame| view.can_view_agent(frame.identity.as_str()));
797 }
798 (
799 StatusCode::OK,
800 Json::<Value>(
801 serde_json::to_value(page).unwrap_or_else(|_| json!({ "frames": [] })),
802 ),
803 )
804 .into_response()
805 }
806 Err(err) => {
807 console_json_error(StatusCode::CONFLICT, "replay_unavailable", &err.to_string())
808 }
809 }
810}
811
812async fn console_send_handler(
813 State(state): State<ConsoleJsonState>,
814 headers: HeaderMap,
815 uri: Uri,
816 Json(request): Json<ConsoleSendRequest>,
817) -> impl IntoResponse {
818 let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
819 return console_json_error(
820 StatusCode::UNAUTHORIZED,
821 "unauthorized",
822 "console send requires a valid auth token",
823 );
824 };
825 if state.decisions.console.read_only {
826 return console_json_error(StatusCode::FORBIDDEN, "read_only", "console is read-only");
827 }
828 if let Err(message) =
829 crate::member_comms_id::validate_public_member_alias("identity", request.identity.as_str())
830 {
831 return console_json_error(StatusCode::BAD_REQUEST, "invalid_request", &message);
832 }
833 if let Some(runtime) = &state.runtime
841 && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
842 {
843 prime_access_cache_from_runtime(runtime, controller).await;
844 }
845 if let Some(view) = auth_context.access_view.as_ref()
846 && view.enforced()
847 && !view.allows_agent(ACTION_AGENT_SEND, request.identity.as_str())
848 {
849 return console_json_error(
850 StatusCode::FORBIDDEN,
851 "access_denied",
852 "you are not allowed to send to this agent",
853 );
854 }
855 if let Some(resolver) = state.operator_resolver.as_ref()
860 && let Some(subject) = auth_context
861 .access_view
862 .as_ref()
863 .and_then(|view| view.subject())
864 {
865 resolver.note_interaction(request.identity.as_str(), subject);
866 }
867 let Some(aggregator) = &state.console_aggregator else {
868 return console_json_error(
869 StatusCode::NOT_FOUND,
870 "unavailable",
871 "console aggregator unavailable",
872 );
873 };
874 if let Some(identity_runtime) = &state.identity_runtime {
875 return match Box::pin(console_send_with_identity_first_fallback(
876 aggregator,
877 identity_runtime.clone(),
878 state.runtime.as_ref(),
879 state.console_events.as_ref(),
880 request,
881 ))
882 .await
883 {
884 Ok(accepted) => (
885 StatusCode::OK,
886 Json::<Value>(
887 serde_json::to_value(accepted).unwrap_or_else(|_| json!({ "accepted": true })),
888 ),
889 )
890 .into_response(),
891 Err(err) => console_send_error_response(err),
892 };
893 }
894 match Box::pin(aggregator.send(request)).await {
895 Ok(accepted) => (
896 StatusCode::OK,
897 Json::<Value>(
898 serde_json::to_value(accepted).unwrap_or_else(|_| json!({ "accepted": true })),
899 ),
900 )
901 .into_response(),
902 Err(err) => console_send_error_response(err),
903 }
904}
905
906async fn console_send_with_identity_first_fallback(
907 aggregator: &MobKitConsoleAggregator,
908 identity_runtime: Arc<crate::identity_first::IdentityRuntime>,
909 runtime: Option<&MobRuntime>,
910 console_events: Option<&ConsoleEventStore>,
911 request: ConsoleSendRequest,
912) -> Result<crate::console_aggregator::ConsoleInteractionAccepted, ConsoleSendError> {
913 let member_send_request = request.clone();
914 match Box::pin(console_send_identity_first(
915 aggregator,
916 identity_runtime,
917 runtime,
918 console_events,
919 request,
920 ))
921 .await
922 {
923 Err(ConsoleSendError::UnknownIdentity(_)) => {
924 Box::pin(aggregator.send(member_send_request)).await
925 }
926 result => result,
927 }
928}
929
930async fn console_send_identity_first(
931 aggregator: &MobKitConsoleAggregator,
932 identity_runtime: Arc<crate::identity_first::IdentityRuntime>,
933 runtime: Option<&MobRuntime>,
934 console_events: Option<&ConsoleEventStore>,
935 mut request: ConsoleSendRequest,
936) -> Result<crate::console_aggregator::ConsoleInteractionAccepted, ConsoleSendError> {
937 let requested_identity = request.identity.clone();
938 let parsed_identity = crate::identity_first::AgentIdentity::parse(request.identity.as_str())
939 .map_err(|err| ConsoleSendError::InvalidRequest(format!("invalid identity: {err}")))?;
940 let content: ContentInput = serde_json::from_value(request.content.clone())
941 .map_err(|err| ConsoleSendError::InvalidContent(err.to_string()))?;
942 if let ContentInput::Text(text) = &content
943 && text.trim().is_empty()
944 {
945 return Err(ConsoleSendError::InvalidContent(
946 "content must be non-empty".to_string(),
947 ));
948 }
949 if let ContentInput::Blocks(blocks) = &content
950 && blocks.is_empty()
951 {
952 return Err(ConsoleSendError::InvalidContent(
953 "content blocks must be non-empty".to_string(),
954 ));
955 }
956 let handling_mode = parse_identity_first_handling_mode(request.handling_mode.as_deref())?;
957
958 let (identity, status) = match identity_runtime.status(&parsed_identity).await {
959 Ok(status) => (parsed_identity, status),
960 Err(original_err) => {
961 let Some(canonical_identity) = Box::pin(resolve_console_send_identity_alias(
962 aggregator,
963 &requested_identity,
964 ))
965 .await
966 else {
967 return Err(identity_runtime_error_to_console_send_error(
968 requested_identity.as_str(),
969 original_err,
970 ));
971 };
972 let identity = crate::identity_first::AgentIdentity::parse(canonical_identity.as_str())
973 .map_err(|err| {
974 ConsoleSendError::InvalidRequest(format!("invalid aliased identity: {err}"))
975 })?;
976 let status = identity_runtime.status(&identity).await.map_err(|_| {
977 identity_runtime_error_to_console_send_error(
978 requested_identity.as_str(),
979 original_err,
980 )
981 })?;
982 request.identity = canonical_identity;
983 (identity, status)
984 }
985 };
986 let session_id = status
987 .session_id
988 .as_ref()
989 .map(std::string::ToString::to_string);
990 let runtime_member_id = status
991 .agent_runtime_id
992 .as_ref()
993 .map(|id| id.as_str().to_string());
994 let accepted = Box::pin(
995 aggregator.reserve_identity_first_interaction(request.clone(), session_id.as_deref()),
996 )
997 .await?;
998
999 if let Some(events) = console_events {
1000 events
1001 .reserve_interaction_value(
1002 identity.as_str(),
1003 runtime_member_id.as_deref(),
1004 &accepted.interaction_id,
1005 &request.origin,
1006 request.content.clone(),
1007 )
1008 .await
1009 .map_err(ConsoleSendError::State)?;
1010 }
1011
1012 if let (Some(runtime), Some(events), Some(runtime_member_id)) = (
1013 runtime,
1014 console_events.cloned(),
1015 runtime_member_id.as_deref(),
1016 ) {
1017 start_identity_first_console_live_projection(
1018 runtime,
1019 events,
1020 identity.as_str(),
1021 runtime_member_id,
1022 )
1023 .await;
1024 }
1025
1026 if handling_mode == meerkat_core::types::HandlingMode::Steer {
1027 let send_result =
1028 if crate::member_comms_id::is_reserved_generated_alias(&requested_identity) {
1029 identity_runtime
1030 .send_with_mode_and_interaction_member_alias_tracked(
1031 &identity,
1032 requested_identity.as_str(),
1033 &content,
1034 handling_mode,
1035 Some(accepted.interaction_id.as_str()),
1036 )
1037 .await
1038 } else {
1039 identity_runtime
1040 .send_with_mode_and_interaction_tracked(
1041 &identity,
1042 &content,
1043 handling_mode,
1044 Some(accepted.interaction_id.as_str()),
1045 )
1046 .await
1047 };
1048 match send_result {
1049 Ok(_) => {
1050 if let Err(err) = aggregator
1051 .mark_steer_interaction_delivered(
1052 &accepted.input_frame_id,
1053 &accepted.interaction_id,
1054 )
1055 .await
1056 {
1057 tracing::warn!(
1058 identity = %identity,
1059 error = %err,
1060 "console identity-first steer was admitted but delivery status projection failed"
1061 );
1062 }
1063 }
1064 Err(err) => {
1065 let _ = aggregator
1066 .mark_interaction_delivery_failed(&accepted.input_frame_id)
1067 .await;
1068 if let Some(events) = console_events {
1069 events
1070 .record_interaction_failure(
1071 identity.as_str(),
1072 accepted.interaction_id.as_str(),
1073 json!({
1074 "origin": request.origin,
1075 "error": err.to_string(),
1076 }),
1077 )
1078 .await;
1079 }
1080 tracing::warn!(
1081 identity = %identity,
1082 error = %err,
1083 "console identity-first steer was accepted but delivery failed"
1084 );
1085 return Err(identity_runtime_error_to_console_send_error(
1086 identity.as_str(),
1087 err,
1088 ));
1089 }
1090 }
1091 return Ok(accepted);
1092 }
1093
1094 let dispatch_aggregator = aggregator.clone();
1095 let dispatch_events = console_events.cloned();
1096 let dispatch_identity = identity.clone();
1097 let dispatch_content = content.clone();
1098 let dispatch_origin = request.origin.clone();
1099 let dispatch_accepted = accepted.clone();
1100 let dispatch_expected_alias =
1101 crate::member_comms_id::is_reserved_generated_alias(&requested_identity)
1102 .then_some(requested_identity);
1103 tokio::spawn(async move {
1104 let send_result = if let Some(expected_alias) = dispatch_expected_alias.as_deref() {
1105 identity_runtime
1106 .send_with_mode_and_interaction_member_alias_tracked(
1107 &dispatch_identity,
1108 expected_alias,
1109 &dispatch_content,
1110 handling_mode,
1111 Some(dispatch_accepted.interaction_id.as_str()),
1112 )
1113 .await
1114 } else {
1115 identity_runtime
1116 .send_with_mode_and_interaction_tracked(
1117 &dispatch_identity,
1118 &dispatch_content,
1119 handling_mode,
1120 Some(dispatch_accepted.interaction_id.as_str()),
1121 )
1122 .await
1123 };
1124 match send_result {
1125 Ok(_) => {
1126 if let Err(err) = dispatch_aggregator
1127 .mark_interaction_delivered(&dispatch_accepted.input_frame_id)
1128 .await
1129 {
1130 tracing::warn!(
1131 identity = %dispatch_identity,
1132 error = %err,
1133 "console identity-first send was accepted but delivery status projection failed"
1134 );
1135 }
1136 }
1137 Err(err) => {
1138 let _ = dispatch_aggregator
1139 .mark_interaction_delivery_failed(&dispatch_accepted.input_frame_id)
1140 .await;
1141 if let Some(events) = dispatch_events {
1142 events
1143 .record_interaction_failure(
1144 dispatch_identity.as_str(),
1145 dispatch_accepted.interaction_id.as_str(),
1146 json!({
1147 "origin": dispatch_origin,
1148 "error": err.to_string(),
1149 }),
1150 )
1151 .await;
1152 }
1153 tracing::warn!(
1154 identity = %dispatch_identity,
1155 error = %err,
1156 "console identity-first send was accepted but delivery failed"
1157 );
1158 }
1159 }
1160 });
1161 Ok(accepted)
1162}
1163
1164async fn start_identity_first_console_live_projection(
1165 runtime: &MobRuntime,
1166 console_events: ConsoleEventStore,
1167 identity: &str,
1168 runtime_member_id: &str,
1169) {
1170 let identity = identity.to_string();
1171 let runtime_member_id = runtime_member_id.to_string();
1172 let mut stream = match runtime
1173 .handle()
1174 .subscribe_agent_events(&crate::member_comms_id::mob_member_id(
1175 runtime_member_id.as_str(),
1176 ))
1177 .await
1178 {
1179 Ok(stream) => stream,
1180 Err(err) => {
1181 tracing::warn!(
1182 identity = %identity,
1183 runtime_member_id = %runtime_member_id,
1184 error = %err,
1185 "console identity-first live projection could not subscribe to agent events"
1186 );
1187 return;
1188 }
1189 };
1190
1191 tokio::spawn(async move {
1192 while let Some(envelope) = stream.next().await {
1193 let event_type = agent_event_type(&envelope.payload).to_string();
1194 let unified = EventEnvelope {
1195 event_id: format!("evt-agent-{}", envelope.event_id),
1196 source: "agent".to_string(),
1197 timestamp_ms: envelope.timestamp_ms,
1198 event: UnifiedEvent::Agent {
1199 agent_id: runtime_member_id.clone(),
1200 event_type: event_type.clone(),
1201 payload: Some(crate::mob_handle_runtime::console_agent_event_payload(
1206 &envelope.payload,
1207 )),
1208 },
1209 };
1210 console_events.project_unified_event(&unified).await;
1211 if matches!(event_type.as_str(), "run_completed" | "run_failed") {
1212 break;
1213 }
1214 }
1215 });
1216}
1217
1218fn parse_identity_first_handling_mode(
1219 value: Option<&str>,
1220) -> Result<meerkat_core::types::HandlingMode, ConsoleSendError> {
1221 match value.unwrap_or("queue") {
1222 "queue" => Ok(meerkat_core::types::HandlingMode::Queue),
1223 "steer" => Ok(meerkat_core::types::HandlingMode::Steer),
1224 other => Err(ConsoleSendError::InvalidHandlingMode(other.to_string())),
1225 }
1226}
1227
1228async fn resolve_console_send_identity_alias(
1229 aggregator: &MobKitConsoleAggregator,
1230 requested_identity: &str,
1231) -> Option<String> {
1232 let identities = Box::pin(aggregator.list_identities()).await.ok()?;
1233 identities
1234 .into_iter()
1235 .find(|record| {
1236 record.identity == requested_identity || record.runtime_member_id == requested_identity
1237 })
1238 .map(|record| record.identity)
1239}
1240
1241fn identity_runtime_error_to_console_send_error(
1242 identity: &str,
1243 err: crate::identity_first::IdentityRuntimeError,
1244) -> ConsoleSendError {
1245 match err {
1246 crate::identity_first::IdentityRuntimeError::UnknownIdentity(_) => {
1247 ConsoleSendError::UnknownIdentity(identity.to_string())
1248 }
1249 crate::identity_first::IdentityRuntimeError::NotAddressable(_) => {
1250 ConsoleSendError::NotAddressable(identity.to_string())
1251 }
1252 crate::identity_first::IdentityRuntimeError::InvalidState { .. } => {
1253 ConsoleSendError::Retired(identity.to_string())
1254 }
1255 other => ConsoleSendError::Dispatch(other.to_string()),
1256 }
1257}
1258
1259async fn console_timeline_stream_handler(
1260 State(state): State<ConsoleJsonState>,
1261 headers: HeaderMap,
1262 uri: Uri,
1263 Query(query): Query<ConsoleTimelineHttpQuery>,
1264) -> impl IntoResponse {
1265 let Some(auth_context) = console_request_auth_context(&state, &headers, &uri) else {
1266 return console_json_error(
1267 StatusCode::UNAUTHORIZED,
1268 "unauthorized",
1269 "console timeline stream requires a valid auth token",
1270 );
1271 };
1272 let access_view = auth_context.access_view.filter(AccessView::enforced);
1273 if access_view.is_some()
1280 && let Some(runtime) = &state.runtime
1281 && let Some(controller) = state
1282 .access
1283 .as_ref()
1284 .filter(|controller| controller.enabled())
1285 {
1286 prime_access_cache_from_runtime(runtime, controller).await;
1287 }
1288 let Some(aggregator) = &state.console_aggregator else {
1289 return console_json_error(
1290 StatusCode::NOT_FOUND,
1291 "unavailable",
1292 "console aggregator unavailable",
1293 );
1294 };
1295 let aggregator = aggregator.clone();
1296 let last_event_id = headers
1297 .get("last-event-id")
1298 .and_then(|value| value.to_str().ok())
1299 .map(str::trim)
1300 .filter(|value| !value.is_empty())
1301 .map(ToString::to_string);
1302 let timeline_query = timeline_query_from_http(query, last_event_id);
1303 let mut rx = aggregator.subscribe();
1304 let (snapshot_frames, snapshot_cursor) =
1305 match Box::pin(query_timeline_snapshot(&aggregator, timeline_query.clone())).await {
1306 Ok(snapshot) => snapshot,
1307 Err(_) => {
1308 let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
1309 let requested_cursor = timeline_query
1310 .after
1311 .as_ref()
1312 .map(ToString::to_string)
1313 .unwrap_or_default();
1314 return (
1315 StatusCode::CONFLICT,
1316 Json::<Value>(
1317 serde_json::to_value(ConsoleReplayUnavailable {
1318 error: "replay_unavailable".to_string(),
1319 requested_cursor,
1320 latest_cursor,
1321 })
1322 .unwrap_or_else(|_| json!({ "error": "replay_unavailable" })),
1323 ),
1324 )
1325 .into_response();
1326 }
1327 };
1328 let identity = timeline_query.identity.clone();
1329 let conversation_id = timeline_query.conversation_id.clone();
1330 let snapshot_after = timeline_query.after.clone();
1331 let stream = stream! {
1332 if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::SnapshotStarted { after: snapshot_after }) {
1333 yield Ok::<Event, Infallible>(event);
1334 }
1335 let mut latest_cursor = snapshot_cursor;
1336 for frame in snapshot_frames {
1337 latest_cursor = Some(frame.cursor.clone());
1338 if access_view.as_ref().is_some_and(|view| !view.can_view_agent(frame.identity.as_str())) {
1339 continue;
1340 }
1341 if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::ConsoleFrame { frame }) {
1342 yield Ok::<Event, Infallible>(event);
1343 }
1344 }
1345 if let Some(event) = sse_event_from_timeline_event(&ConsoleTimelineEvent::SnapshotComplete { cursor: latest_cursor.clone() }) {
1346 yield Ok::<Event, Infallible>(event);
1347 }
1348 loop {
1349 match rx.recv().await {
1350 Ok(event) if timeline_event_matches(&event, identity.as_deref(), conversation_id.as_deref()) => {
1351 if !Box::pin(
1352 aggregator
1353 .timeline_event_visible_for_subscriber(&event, identity.as_deref()),
1354 )
1355 .await
1356 {
1357 continue;
1358 }
1359 if let Some(view) = access_view.as_ref()
1360 && let Some(frame_identity) = timeline_event_identity(&event)
1361 && !view.can_view_agent(frame_identity)
1362 {
1363 continue;
1364 }
1365 if let Some(event_cursor) = timeline_event_cursor(&event)
1366 && let Some(current_cursor) = latest_cursor.as_ref()
1367 && !cursor_is_after(event_cursor, current_cursor)
1368 {
1369 continue;
1370 }
1371 if let Some(sse) = sse_event_from_timeline_event(&event) {
1372 if let Some(event_cursor) = timeline_event_cursor(&event) {
1373 latest_cursor = Some(event_cursor.clone());
1374 }
1375 yield Ok::<Event, Infallible>(sse);
1376 }
1377 }
1378 Ok(_) => {}
1379 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => break,
1380 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
1381 }
1382 }
1383 };
1384 Sse::new(stream)
1385 .keep_alive(
1386 KeepAlive::new()
1387 .interval(DEFAULT_KEEP_ALIVE_INTERVAL)
1388 .text(KEEP_ALIVE_TEXT),
1389 )
1390 .into_response()
1391}
1392
1393async fn console_identity_timeline_stream_handler(
1394 State(state): State<ConsoleJsonState>,
1395 headers: HeaderMap,
1396 uri: Uri,
1397 AxumPath(identity): AxumPath<String>,
1398 Query(mut query): Query<ConsoleTimelineHttpQuery>,
1399) -> impl IntoResponse {
1400 query.identity = Some(identity);
1401 Box::pin(console_timeline_stream_handler(
1402 State(state),
1403 headers,
1404 uri,
1405 Query(query),
1406 ))
1407 .await
1408 .into_response()
1409}
1410
1411fn timeline_query_from_http(
1412 query: ConsoleTimelineHttpQuery,
1413 fallback_after: Option<String>,
1414) -> ConsoleTimelineWindowQuery {
1415 let after = fallback_after.or(query.after).map(ConsoleCursor::from);
1416 let before = query.before.map(ConsoleCursor::from);
1417 ConsoleTimelineWindowQuery {
1418 identity: query
1419 .identity
1420 .map(|value| value.trim().to_string())
1421 .filter(|value| !value.is_empty()),
1422 conversation_id: query
1423 .conversation_id
1424 .map(|value| value.trim().to_string())
1425 .filter(|value| !value.is_empty()),
1426 after,
1427 before,
1428 mode: query.mode.unwrap_or_default(),
1429 limit: query.limit.unwrap_or(200),
1430 }
1431}
1432
1433async fn query_timeline_snapshot(
1434 aggregator: &MobKitConsoleAggregator,
1435 mut query: ConsoleTimelineWindowQuery,
1436) -> ConsoleLogResult<(Vec<ConsoleFrame>, Option<ConsoleCursor>)> {
1437 const DEFAULT_SNAPSHOT_LIMIT: usize = 200;
1438 query.limit = if query.limit == 0 {
1439 DEFAULT_SNAPSHOT_LIMIT
1440 } else {
1441 query.limit
1442 };
1443 if query.after.is_none() && query.mode == ConsoleTimelineMode::Since {
1444 query.mode = ConsoleTimelineMode::Recent;
1445 }
1446 let mode = query.mode;
1447 match mode {
1448 ConsoleTimelineMode::Recent => {
1449 let page = Box::pin(aggregator.query_timeline_windowed(query)).await?;
1450 Ok((page.frames, page.latest_cursor.or(page.next_cursor)))
1451 }
1452 ConsoleTimelineMode::Since => {
1453 if let (Some(after), Some(latest)) =
1454 (query.after.as_ref(), aggregator.latest_cursor().await?)
1455 && let (Some(after_seq), Some(latest_seq)) = (after.seq(), latest.seq())
1456 && after_seq > latest_seq
1457 {
1458 return Err(std::io::Error::other(
1459 "timeline replay cursor is beyond the current store frontier",
1460 )
1461 .into());
1462 }
1463 let mut frames = Vec::new();
1464 let mut cursor = query.after.clone();
1465 let mut latest_cursor = None;
1466 loop {
1467 let page = Box::pin(aggregator.query_timeline_windowed(query.clone())).await?;
1468 latest_cursor = page.latest_cursor.clone().or(latest_cursor);
1469 if !page.frames.is_empty() {
1470 cursor = page
1471 .next_cursor
1472 .clone()
1473 .or_else(|| page.frames.last().map(|frame| frame.cursor.clone()));
1474 frames.extend(page.frames);
1475 } else if page.next_cursor.is_some() {
1476 cursor = page.next_cursor.clone();
1477 }
1478 if page.exhausted || page.next_cursor.is_none() {
1479 return Ok((frames, cursor.or(latest_cursor)));
1480 }
1481 if page.next_cursor == query.after {
1482 return Err(
1483 std::io::Error::other("timeline replay made no cursor progress").into(),
1484 );
1485 }
1486 query.after = page.next_cursor;
1487 }
1488 }
1489 }
1490}
1491
1492fn console_json_error(status: StatusCode, error: &str, message: &str) -> axum::response::Response {
1493 (
1494 status,
1495 Json::<Value>(json!({
1496 "error": error,
1497 "message": message,
1498 })),
1499 )
1500 .into_response()
1501}
1502
1503fn is_console_mutating_rpc_method(method: &str) -> bool {
1504 if crate::rpc::topology_methods::is_topology_mutating_method(method) {
1505 return true;
1506 }
1507 if crate::rpc::workgraph_methods::is_workgraph_mutating_method(method) {
1508 return true;
1509 }
1510 matches!(
1511 method,
1512 "mobkit/retire"
1513 | "mobkit/reset_all"
1514 | "mobkit/console/send"
1515 | "mobkit/blob/upload"
1516 | "mobkit/ensure_member"
1517 | "mobkit/retire_member"
1518 | "mobkit/respawn_member"
1519 | "mobkit/force_cancel_member"
1520 | "mobkit/cancel_flow"
1521 | "mobkit/collect_completed"
1522 | "mobkit/run_flow"
1523 | "mobkit/spawn_helper"
1524 | "mobkit/fork_helper"
1525 | "mobkit/attach_existing_session"
1526 | "mobkit/reconcile_edges"
1527 | "mobkit/cross_mob/wire_local"
1528 | "mobkit/cross_mob/unwire_local"
1529 | "mobkit/respawn"
1530 | "mobkit/reset"
1531 | "mobkit/delete_identity"
1532 | "mobkit/agent_memory/remember"
1533 | "mobkit/agent_memory/update"
1534 | "mobkit/agent_memory/forget"
1535 | "mobkit/gating/decide"
1536 | "mobkit/mob_labels/set"
1537 | "mobkit/mob_labels/delete"
1538 | "mobkit/run_labels/set"
1539 | "mobkit/run_labels/delete"
1540 | "mobkit/access/set"
1541 | "mobkit/access/enable"
1542 | "mobkit/access/rules/upsert"
1543 | "mobkit/access/rules/delete"
1544 | "mobkit/access/groups/set"
1545 | "mobkit/access/groups/delete"
1546 )
1547}
1548
1549fn console_read_only_rpc_error(response_id: Value) -> Value {
1550 response_value(
1551 response_id,
1552 None,
1553 Some(JsonRpcError {
1554 code: -32010,
1555 message: "console is read-only".to_string(),
1556 data: Some(json!({ "kind": "read_only" })),
1557 }),
1558 )
1559}
1560
1561fn console_send_error_response(err: ConsoleSendError) -> axum::response::Response {
1562 let (status, code) = match &err {
1563 ConsoleSendError::UnknownIdentity(_) => (StatusCode::NOT_FOUND, "unknown_identity"),
1564 ConsoleSendError::AmbiguousIdentity { .. } => {
1565 (StatusCode::CONFLICT, "ambiguous_live_identity_alias")
1566 }
1567 ConsoleSendError::NotAddressable(_) => (StatusCode::CONFLICT, "not_addressable"),
1568 ConsoleSendError::Retired(_) => (StatusCode::CONFLICT, "retired"),
1569 ConsoleSendError::InvalidContent(_)
1570 | ConsoleSendError::InvalidHandlingMode(_)
1571 | ConsoleSendError::InvalidRequest(_) => (StatusCode::BAD_REQUEST, "invalid_request"),
1572 ConsoleSendError::IdempotencyConflict(_) => (StatusCode::CONFLICT, "idempotency_conflict"),
1573 ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1574 (StatusCode::INTERNAL_SERVER_ERROR, "internal_error")
1575 }
1576 };
1577 console_json_error(status, code, &console_send_public_message(&err))
1578}
1579
1580fn console_send_rpc_code(err: &ConsoleSendError) -> i64 {
1581 match err {
1582 ConsoleSendError::UnknownIdentity(_) => -32001,
1583 ConsoleSendError::AmbiguousIdentity { .. } => -32602,
1584 ConsoleSendError::NotAddressable(_) => -32002,
1585 ConsoleSendError::InvalidContent(_)
1586 | ConsoleSendError::InvalidHandlingMode(_)
1587 | ConsoleSendError::InvalidRequest(_) => -32602,
1588 ConsoleSendError::IdempotencyConflict(_) => -32009,
1589 ConsoleSendError::Retired(_) => -32004,
1590 ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1591 -32000
1592 }
1593 }
1594}
1595
1596fn console_send_rpc_error(response_id: Value, err: ConsoleSendError) -> Value {
1597 response_value(response_id, None, Some(console_send_json_rpc_error(err)))
1598}
1599
1600fn console_send_json_rpc_error(err: ConsoleSendError) -> JsonRpcError {
1601 let code = console_send_rpc_code(&err);
1602 JsonRpcError {
1603 code,
1604 message: console_send_public_message(&err),
1605 data: None,
1606 }
1607}
1608
1609fn console_send_public_message(err: &ConsoleSendError) -> String {
1610 match err {
1611 ConsoleSendError::State(_) | ConsoleSendError::Dispatch(_) | ConsoleSendError::Log(_) => {
1612 tracing::warn!(target: "mobkit::console", error = %err, "console send internal error");
1613 "console send failed".to_string()
1614 }
1615 _ => err.to_string(),
1616 }
1617}
1618
1619fn timeline_event_matches(
1620 event: &ConsoleTimelineEvent,
1621 identity: Option<&str>,
1622 conversation_id: Option<&str>,
1623) -> bool {
1624 let frame = match event {
1625 ConsoleTimelineEvent::ConsoleFrame { frame }
1626 | ConsoleTimelineEvent::FrameUpdated { frame } => frame,
1627 ConsoleTimelineEvent::SnapshotStarted { .. }
1628 | ConsoleTimelineEvent::SnapshotComplete { .. }
1629 | ConsoleTimelineEvent::ReplayUnavailable { .. } => return true,
1630 };
1631 if identity.is_some_and(|value| frame.identity != value) {
1632 return false;
1633 }
1634 if conversation_id.is_some_and(|value| frame.conversation_id.as_deref() != Some(value)) {
1635 return false;
1636 }
1637 true
1638}
1639
1640fn timeline_event_cursor(event: &ConsoleTimelineEvent) -> Option<&ConsoleCursor> {
1641 match event {
1642 ConsoleTimelineEvent::ConsoleFrame { frame }
1643 | ConsoleTimelineEvent::FrameUpdated { frame } => Some(&frame.cursor),
1644 ConsoleTimelineEvent::SnapshotStarted { .. }
1645 | ConsoleTimelineEvent::SnapshotComplete { .. }
1646 | ConsoleTimelineEvent::ReplayUnavailable { .. } => None,
1647 }
1648}
1649
1650fn timeline_event_identity(event: &ConsoleTimelineEvent) -> Option<&str> {
1651 match event {
1652 ConsoleTimelineEvent::ConsoleFrame { frame }
1653 | ConsoleTimelineEvent::FrameUpdated { frame } => Some(frame.identity.as_str()),
1654 ConsoleTimelineEvent::SnapshotStarted { .. }
1655 | ConsoleTimelineEvent::SnapshotComplete { .. }
1656 | ConsoleTimelineEvent::ReplayUnavailable { .. } => None,
1657 }
1658}
1659
1660pub(crate) const ACCESS_DENIED_RPC_CODE: i64 = -32030;
1665
1666fn retain_visible_timeline_frames(page: &mut ConsoleTimelineWindowPage, view: Option<&AccessView>) {
1667 let Some(view) = view.filter(|view| view.enforced()) else {
1668 return;
1669 };
1670 page.frames
1671 .retain(|frame| view.can_view_agent(frame.identity.as_str()));
1672}
1673
1674fn retain_visible_member_rows(rows: &mut Vec<Value>, view: Option<&AccessView>) {
1678 let Some(view) = view.filter(|view| view.enforced()) else {
1679 return;
1680 };
1681 rows.retain(|row| {
1682 let labels: Option<std::collections::BTreeMap<String, String>> = row
1683 .get("labels")
1684 .and_then(|value| serde_json::from_value(value.clone()).ok());
1685 let agent_id = row
1686 .get("agent_identity")
1687 .or_else(|| row.get("member_id"))
1688 .or_else(|| row.get("agent_id"))
1689 .and_then(Value::as_str);
1690 let identity = labels
1691 .as_ref()
1692 .and_then(crate::member_comms_id::durable_identity_label)
1693 .or_else(|| row.get("identity").and_then(Value::as_str))
1694 .or(agent_id);
1695 let role = row
1696 .get("role")
1697 .or_else(|| row.get("profile"))
1698 .and_then(Value::as_str);
1699 view.decide(
1700 ACTION_AGENT_VIEW,
1701 &AccessResource {
1702 identity,
1703 agent_id,
1704 role,
1705 labels: labels.as_ref(),
1706 },
1707 )
1708 .is_allow()
1709 });
1710}
1711
1712fn retain_visible_identity_records(
1713 identities: &mut Vec<ConsoleIdentityRecord>,
1714 view: Option<&AccessView>,
1715) {
1716 let Some(view) = view.filter(|view| view.enforced()) else {
1717 return;
1718 };
1719 identities.retain(|record| {
1720 view.decide(
1721 ACTION_AGENT_VIEW,
1722 &AccessResource {
1723 identity: Some(record.identity.as_str()),
1724 agent_id: Some(record.runtime_member_id.as_str()),
1725 role: None,
1726 labels: Some(&record.labels),
1727 },
1728 )
1729 .is_allow()
1730 });
1731}
1732
1733fn console_rpc_access_requirements(
1739 method: &str,
1740 params: &Value,
1741) -> Option<Vec<(&'static str, Option<String>)>> {
1742 let identity = normalized_console_rpc_string_param(params, "identity");
1743 let target = identity
1744 .clone()
1745 .or_else(|| normalized_console_rpc_string_param(params, "member_id"))
1746 .or_else(|| normalized_console_rpc_string_param(params, "agent_id"));
1747 let one = |action: &'static str, target: Option<String>| Some(vec![(action, target)]);
1748 match method {
1749 "mobkit/console/send" => one(ACTION_AGENT_SEND, identity),
1750 "mobkit/agent_memory/remember" => one(ACTION_AGENT_MEMORY_WRITE, identity),
1751 "mobkit/agent_memory/update" => one(ACTION_AGENT_MEMORY_WRITE, identity),
1753 "mobkit/agent_memory/forget" => one(ACTION_AGENT_MEMORY_DELETE, identity),
1754 "mobkit/agent_memory/recall" | "mobkit/agent_memory/manifest" => Some(vec![
1759 (ACTION_AGENT_MEMORY_READ, identity.clone()),
1760 (ACTION_AGENT_VIEW, identity),
1761 ]),
1762 "mobkit/memory/panel/records" => match memory_panel_scope_param(params) {
1769 Some(scope) if scope == "mob" => one(ACTION_MOB_MEMORY_READ, None),
1770 Some(scope) if scope == "operator" => one(ACTION_OPERATOR_MEMORY_READ, None),
1771 _ if identity.is_some() => Some(vec![
1772 (ACTION_AGENT_MEMORY_READ, identity.clone()),
1773 (ACTION_AGENT_VIEW, identity),
1774 ]),
1775 _ => None,
1776 },
1777 "mobkit/memory/panel/dreams" => one(ACTION_AGENT_MEMORY_READ, None),
1781 "mobkit/memory/panel/dream_runs" => one(ACTION_AGENT_MEMORY_READ, None),
1784 "mobkit/memory/panel/audit_verdicts" => one(ACTION_AGENT_MEMORY_READ, None),
1785 "mobkit/memory/panel/overview" => one(ACTION_AGENT_MEMORY_READ, None),
1790 "mobkit/memory/panel/proposals" => one(ACTION_AGENT_MEMORY_READ, None),
1791 "mobkit/memory/panel/injections" => one(ACTION_AGENT_MEMORY_READ, None),
1792 "mobkit/memory/panel/harvests" => one(ACTION_AGENT_MEMORY_READ, None),
1793 "mobkit/memory/panel/quarantine" => one(ACTION_MEMORY_QUARANTINE_REVIEW, None),
1794 "mobkit/retire"
1799 | "mobkit/retire_member"
1800 | "mobkit/force_cancel_member"
1801 | "mobkit/delete_identity" => one(ACTION_AGENT_RETIRE, target),
1802 "mobkit/respawn" | "mobkit/respawn_member" => one(ACTION_AGENT_RESPAWN, target),
1803 "mobkit/reset" | "mobkit/reset_all" => one(ACTION_AGENT_RESET, target),
1804 "mobkit/ensure_member"
1805 | "mobkit/spawn_helper"
1806 | "mobkit/fork_helper"
1807 | "mobkit/attach_existing_session"
1808 | "mobkit/run_flow"
1809 | "mobkit/cancel_flow"
1810 | "mobkit/collect_completed" => one(ACTION_AGENT_SPAWN, target),
1811 "mobkit/flow_status" | "mobkit/list_flows" | "mobkit/list_runs" => {
1814 one(ACTION_AGENT_SPAWN, None)
1815 }
1816 "mobkit/gating/decide" => one(ACTION_GATING_DECIDE, None),
1817 "mobkit/gating/pending" | "mobkit/gating/audit" => one(ACTION_GATING_VIEW, None),
1818 "mobkit/mob_events/query" | "mobkit/mob_events/subscribe" => one(ACTION_MOB_OBSERVE, None),
1819 "mobkit/reconcile_edges"
1820 | "mobkit/cross_mob/wire_local"
1821 | "mobkit/cross_mob/unwire_local"
1822 | "mobkit/mob_labels/set"
1823 | "mobkit/mob_labels/delete"
1824 | "mobkit/run_labels/set"
1825 | "mobkit/run_labels/delete"
1826 | "mobkit/routing/routes/list"
1831 | "mobkit/delivery/history"
1832 | "mobkit/cross_mob/directory"
1833 | "mobkit/cross_mob/peer_info"
1834 | "mobkit/mob_labels/get"
1835 | "mobkit/run_labels/get"
1836 | "mobkit/storage/doctor" => one(ACTION_RUNTIME_ADMIN, None),
1839 "mobkit/get_member"
1840 | "mobkit/member_status"
1841 | "mobkit/identity/resolved_tools"
1842 | "mobkit/inspect_identity"
1843 | "mobkit/status_identity"
1844 | "mobkit/console/inspect_identity" => one(ACTION_AGENT_VIEW, target),
1845 method if crate::rpc::workgraph_methods::is_workgraph_mutating_method(method) => {
1848 one(ACTION_WORKGRAPH_MANAGE, None)
1849 }
1850 method if crate::rpc::workgraph_methods::is_workgraph_read_method(method) => {
1851 one(ACTION_WORKGRAPH_VIEW, None)
1852 }
1853 _ => None,
1854 }
1855}
1856
1857fn memory_panel_scope_param(params: &Value) -> Option<String> {
1858 normalized_console_rpc_string_param(params, "scope").filter(|scope| !scope.is_empty())
1859}
1860
1861fn normalized_console_rpc_string_param(params: &Value, key: &str) -> Option<String> {
1862 params
1863 .get(key)
1864 .and_then(Value::as_str)
1865 .map(str::trim)
1866 .map(str::to_string)
1867}
1868
1869fn console_rpc_access_violation(
1872 view: Option<&AccessView>,
1873 method: &str,
1874 params: &Value,
1875) -> Option<JsonRpcError> {
1876 let view = view.filter(|view| view.enforced())?;
1877 let requirements = console_rpc_access_requirements(method, params)?;
1878 for (action, target) in requirements {
1879 let allowed = match target {
1880 Some(ref identity) => view.allows_agent(action, identity),
1881 None => view.allows(action),
1882 };
1883 if !allowed {
1884 return Some(JsonRpcError {
1885 code: ACCESS_DENIED_RPC_CODE,
1886 message: format!("access denied: {action}"),
1887 data: Some(json!({
1888 "kind": "access_denied",
1889 "action": action,
1890 "resource": target,
1891 })),
1892 });
1893 }
1894 }
1895 None
1896}
1897
1898fn access_denied_rpc_error(response_id: Value, message: impl Into<String>) -> Value {
1899 response_value(
1900 response_id,
1901 None,
1902 Some(JsonRpcError {
1903 code: ACCESS_DENIED_RPC_CODE,
1904 message: message.into(),
1905 data: Some(json!({ "kind": "access_denied" })),
1906 }),
1907 )
1908}
1909
1910fn access_unavailable_rpc_error(response_id: Value) -> Value {
1911 response_value(
1912 response_id,
1913 None,
1914 Some(JsonRpcError {
1915 code: -32004,
1916 message: "access control is not configured on this runtime".to_string(),
1917 data: Some(json!({ "kind": "access_unavailable" })),
1918 }),
1919 )
1920}
1921
1922fn access_config_rpc_error(response_id: Value, err: crate::access::AccessConfigError) -> Value {
1923 response_value(
1924 response_id,
1925 None,
1926 Some(JsonRpcError {
1927 code: -32602,
1928 message: err.to_string(),
1929 data: Some(json!({ "kind": "invalid_access_config" })),
1930 }),
1931 )
1932}
1933
1934const MEMORY_PANEL_DEFAULT_LIMIT: usize = 50;
1939const MEMORY_PANEL_MAX_LIMIT: usize = 200;
1940const MEMORY_PANEL_CHAIN_MAX: usize = 32;
1941const MEMORY_PANEL_INJECTIONS_MAX: usize = 50;
1942const MEMORY_PANEL_DREAMS_DEFAULT: usize = 20;
1943const MEMORY_PANEL_DREAMS_MAX: usize = 100;
1944
1945fn memory_panel_unavailable(response_id: Value) -> Value {
1946 response_value(
1947 response_id,
1948 None,
1949 Some(JsonRpcError {
1950 code: -32601,
1951 message: "memory panel is not configured".to_string(),
1952 data: None,
1953 }),
1954 )
1955}
1956
1957fn memory_panel_store_error(
1958 response_id: Value,
1959 err: crate::identity_first::agent_memory::AgentMemoryError,
1960) -> Value {
1961 response_value(
1962 response_id,
1963 None,
1964 Some(crate::rpc::agent_memory_rpc_error("panel", err)),
1965 )
1966}
1967
1968fn memory_panel_limit_param(params: &Value, default: usize, max: usize) -> usize {
1969 params
1970 .get("limit")
1971 .and_then(Value::as_u64)
1972 .map(|limit| (limit as usize).clamp(1, max))
1973 .unwrap_or(default)
1974}
1975
1976fn encode_memory_panel_cursor(cursor: &(u64, String)) -> String {
1977 format!("{}:{}", cursor.0, cursor.1)
1978}
1979
1980fn parse_memory_panel_cursor(raw: &str) -> Option<(u64, String)> {
1981 let (ms, id) = raw.split_once(':')?;
1982 let ms = ms.parse::<u64>().ok()?;
1983 (!id.is_empty()).then(|| (ms, id.to_string()))
1984}
1985
1986fn memory_panel_scope_action(scope: &crate::memory::records::MemoryScope) -> &'static str {
1991 match scope {
1992 crate::memory::records::MemoryScope::Identity { .. } => ACTION_AGENT_MEMORY_READ,
1993 crate::memory::records::MemoryScope::Mob { .. } => ACTION_MOB_MEMORY_READ,
1994 crate::memory::records::MemoryScope::Operator { .. } => ACTION_OPERATOR_MEMORY_READ,
1995 crate::memory::records::MemoryScope::Realm { .. } => ACTION_AGENT_MEMORY_READ,
1996 }
1997}
1998
1999fn memory_panel_record_visible(
2009 view: Option<&AccessView>,
2010 record: &crate::memory::records::MemoryRecord,
2011) -> bool {
2012 let Some(view) = view.filter(|view| view.enforced()) else {
2013 return true;
2014 };
2015 let scope_allowed = match &record.scope {
2016 crate::memory::records::MemoryScope::Identity { identity, .. } => {
2017 view.allows_agent(ACTION_AGENT_MEMORY_READ, identity)
2018 && view.allows_agent(ACTION_AGENT_VIEW, identity)
2019 }
2020 crate::memory::records::MemoryScope::Mob { .. } => view.allows(ACTION_MOB_MEMORY_READ),
2021 crate::memory::records::MemoryScope::Operator { .. } => {
2022 view.allows(ACTION_OPERATOR_MEMORY_READ)
2023 }
2024 crate::memory::records::MemoryScope::Realm { .. } => view.allows(ACTION_AGENT_MEMORY_READ),
2025 };
2026 if !scope_allowed {
2027 return false;
2028 }
2029 if matches!(
2030 record.status,
2031 crate::memory::records::RecordStatus::Quarantined { .. }
2032 ) {
2033 return view.allows(ACTION_MEMORY_QUARANTINE_REVIEW);
2034 }
2035 true
2036}
2037
2038fn memory_panel_promotion_visible(
2045 view: Option<&AccessView>,
2046 scope_kind: &str,
2047 scope_key: &str,
2048) -> bool {
2049 let Some(view) = view.filter(|view| view.enforced()) else {
2050 return true;
2051 };
2052 match scope_kind {
2053 "identity" => {
2054 view.allows_agent(ACTION_AGENT_MEMORY_READ, scope_key)
2055 && view.allows_agent(ACTION_AGENT_VIEW, scope_key)
2056 }
2057 "mob" => view.allows(ACTION_MOB_MEMORY_READ),
2058 "operator" => view.allows(ACTION_OPERATOR_MEMORY_READ),
2059 "realm" => view.allows(ACTION_AGENT_MEMORY_READ),
2060 _ => false,
2061 }
2062}
2063
2064fn memory_panel_record_json(
2067 record: &crate::memory::records::MemoryRecord,
2068 include_body: bool,
2069) -> Value {
2070 let mut value = serde_json::to_value(record).unwrap_or(Value::Null);
2071 if !include_body && let Some(object) = value.as_object_mut() {
2072 object.remove("body");
2073 object.insert("body_bytes".to_string(), json!(record.body.len()));
2074 }
2075 value
2076}
2077
2078async fn memory_panel_realms(
2081 store: &dyn crate::memory::capabilities::MemoryPanelStore,
2082 params: &Value,
2083) -> Result<Vec<String>, crate::identity_first::agent_memory::AgentMemoryError> {
2084 match normalized_console_rpc_string_param(params, "realm").filter(|realm| !realm.is_empty()) {
2085 Some(realm) => Ok(vec![realm]),
2086 None => store.panel_realms().await,
2087 }
2088}
2089
2090async fn handle_memory_panel_records(
2091 store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2092 view: Option<&AccessView>,
2093 params: &Value,
2094 response_id: Value,
2095) -> Value {
2096 let Some(store) = store else {
2097 return memory_panel_unavailable(response_id);
2098 };
2099 let scope_kind = memory_panel_scope_param(params);
2100 if let Some(kind) = &scope_kind
2101 && !matches!(kind.as_str(), "identity" | "mob" | "operator" | "realm")
2102 {
2103 return invalid_params(
2104 response_id,
2105 format!("Invalid params: unknown scope '{kind}'"),
2106 );
2107 }
2108 let identity = normalized_console_rpc_string_param(params, "identity")
2109 .filter(|identity| !identity.is_empty());
2110 let scope_kind = scope_kind.or_else(|| identity.as_ref().map(|_| "identity".to_string()));
2111 let scope_key = normalized_console_rpc_string_param(params, "scope_key")
2112 .filter(|key| !key.is_empty())
2113 .or(identity);
2114 let status =
2115 normalized_console_rpc_string_param(params, "status").filter(|status| !status.is_empty());
2116 if let Some(status) = &status
2117 && !matches!(
2118 status.as_str(),
2119 "active" | "quarantined" | "superseded" | "tombstoned"
2120 )
2121 {
2122 return invalid_params(
2123 response_id,
2124 format!("Invalid params: unknown status '{status}'"),
2125 );
2126 }
2127 let limit =
2128 memory_panel_limit_param(params, MEMORY_PANEL_DEFAULT_LIMIT, MEMORY_PANEL_MAX_LIMIT);
2129 let cursor = normalized_console_rpc_string_param(params, "cursor")
2130 .filter(|cursor| !cursor.is_empty())
2131 .map(|raw| parse_memory_panel_cursor(&raw));
2132 let cursor = match cursor {
2133 Some(None) => {
2134 return invalid_params(response_id, "Invalid params: malformed cursor".to_string());
2135 }
2136 Some(Some(cursor)) => Some(cursor),
2137 None => None,
2138 };
2139 let realms = match memory_panel_realms(store, params).await {
2140 Ok(realms) => realms,
2141 Err(err) => return memory_panel_store_error(response_id, err),
2142 };
2143 let single_realm = realms.len() == 1;
2146 let mut records = Vec::new();
2147 let mut next_cursor = None;
2148 for realm in &realms {
2149 let page = match store
2150 .records_page(
2151 realm,
2152 scope_kind.as_deref(),
2153 scope_key.as_deref(),
2154 status.as_deref(),
2155 limit,
2156 if single_realm { cursor.clone() } else { None },
2157 )
2158 .await
2159 {
2160 Ok(page) => page,
2161 Err(err) => return memory_panel_store_error(response_id, err),
2162 };
2163 if single_realm {
2164 next_cursor = page.next_cursor.as_ref().map(encode_memory_panel_cursor);
2165 }
2166 records.extend(page.records);
2167 }
2168 records.retain(|record| memory_panel_record_visible(view, record));
2169 records.sort_by(|a, b| {
2170 b.updated_at_ms
2171 .cmp(&a.updated_at_ms)
2172 .then_with(|| b.id.cmp(&a.id))
2173 });
2174 records.truncate(limit);
2175 let rows: Vec<Value> = records
2176 .iter()
2177 .map(|record| memory_panel_record_json(record, false))
2178 .collect();
2179 response_value(
2180 response_id,
2181 Some(json!({
2182 "records": rows,
2183 "next_cursor": next_cursor,
2184 "realms": realms,
2185 })),
2186 None,
2187 )
2188}
2189
2190async fn handle_memory_panel_record(
2191 store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2192 view: Option<&AccessView>,
2193 params: &Value,
2194 response_id: Value,
2195) -> Value {
2196 let Some(store) = store else {
2197 return memory_panel_unavailable(response_id);
2198 };
2199 let Some(memory_id) = normalized_console_rpc_string_param(params, "memory_id")
2200 .filter(|memory_id| !memory_id.is_empty())
2201 else {
2202 return invalid_params(
2203 response_id,
2204 "Invalid params: memory_id is required".to_string(),
2205 );
2206 };
2207 let realms = match memory_panel_realms(store, params).await {
2208 Ok(realms) => realms,
2209 Err(err) => return memory_panel_store_error(response_id, err),
2210 };
2211 let mut found = None;
2212 for realm in &realms {
2213 match store.record_by_id(realm, &memory_id).await {
2214 Ok(Some(record)) => {
2215 found = Some((realm.clone(), record));
2216 break;
2217 }
2218 Ok(None) => {}
2219 Err(err) => return memory_panel_store_error(response_id, err),
2220 }
2221 }
2222 let Some((realm, record)) = found else {
2223 return response_value(
2224 response_id,
2225 None,
2226 Some(JsonRpcError {
2227 code: -32001,
2228 message: format!("unknown memory record '{memory_id}'"),
2229 data: None,
2230 }),
2231 );
2232 };
2233 if !memory_panel_record_visible(view, &record) {
2236 let action = if matches!(
2237 record.status,
2238 crate::memory::records::RecordStatus::Quarantined { .. }
2239 ) && view
2240 .is_some_and(|view| view.enforced() && !view.allows(ACTION_MEMORY_QUARANTINE_REVIEW))
2241 {
2242 ACTION_MEMORY_QUARANTINE_REVIEW
2243 } else {
2244 memory_panel_scope_action(&record.scope)
2245 };
2246 return response_value(
2247 response_id,
2248 None,
2249 Some(JsonRpcError {
2250 code: ACCESS_DENIED_RPC_CODE,
2251 message: format!("access denied: {action}"),
2252 data: Some(json!({
2253 "kind": "access_denied",
2254 "action": action,
2255 "resource": record.id,
2256 })),
2257 }),
2258 );
2259 }
2260 let chain = match store
2261 .supersede_chain(&realm, &memory_id, MEMORY_PANEL_CHAIN_MAX)
2262 .await
2263 {
2264 Ok(chain) => chain,
2265 Err(err) => return memory_panel_store_error(response_id, err),
2266 };
2267 let chain_rows: Vec<Value> = chain
2268 .iter()
2269 .filter(|link| memory_panel_record_visible(view, link))
2270 .map(|link| memory_panel_record_json(link, false))
2271 .collect();
2272 let injections = match store
2273 .injection_log_for_record(&realm, &memory_id, MEMORY_PANEL_INJECTIONS_MAX)
2274 .await
2275 {
2276 Ok(entries) => entries,
2277 Err(err) => return memory_panel_store_error(response_id, err),
2278 };
2279 response_value(
2280 response_id,
2281 Some(json!({
2282 "realm": realm,
2283 "record": memory_panel_record_json(&record, true),
2284 "chain": chain_rows,
2285 "injections": injections,
2286 })),
2287 None,
2288 )
2289}
2290
2291async fn handle_memory_panel_quarantine(
2292 store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2293 view: Option<&AccessView>,
2294 params: &Value,
2295 response_id: Value,
2296) -> Value {
2297 let Some(store) = store else {
2298 return memory_panel_unavailable(response_id);
2299 };
2300 let limit =
2301 memory_panel_limit_param(params, MEMORY_PANEL_DEFAULT_LIMIT, MEMORY_PANEL_MAX_LIMIT);
2302 let realms = match memory_panel_realms(store, params).await {
2303 Ok(realms) => realms,
2304 Err(err) => return memory_panel_store_error(response_id, err),
2305 };
2306 let mut records = Vec::new();
2307 let mut pending = Vec::new();
2308 for realm in &realms {
2309 match store.quarantined_records(realm, limit).await {
2310 Ok(rows) => records.extend(rows),
2311 Err(err) => return memory_panel_store_error(response_id, err),
2312 }
2313 match store.pending_promotions(realm).await {
2314 Ok(rows) => pending.extend(
2315 rows.into_iter()
2316 .filter(|promotion| {
2317 memory_panel_promotion_visible(
2318 view,
2319 &promotion.scope_kind,
2320 &promotion.scope_key,
2321 )
2322 })
2323 .map(|promotion| {
2324 json!({
2326 "realm": realm,
2327 "pending_id": promotion.pending_id,
2328 "record_id": promotion.record_id,
2329 "scope_kind": promotion.scope_kind,
2330 "scope_key": promotion.scope_key,
2331 "rationale": promotion.rationale,
2332 "status": promotion.status,
2333 "created_at_ms": promotion.created_at_ms,
2334 })
2335 }),
2336 ),
2337 Err(err) => return memory_panel_store_error(response_id, err),
2338 }
2339 }
2340 records.retain(|record| memory_panel_record_visible(view, record));
2344 records.sort_by_key(|record| std::cmp::Reverse(record.created_at_ms));
2345 records.truncate(limit);
2346 let rows: Vec<Value> = records
2349 .iter()
2350 .map(|record| memory_panel_record_json(record, false))
2351 .collect();
2352 response_value(
2353 response_id,
2354 Some(json!({
2355 "records": rows,
2356 "pending_promotions": pending,
2357 "realms": realms,
2358 })),
2359 None,
2360 )
2361}
2362
2363async fn handle_memory_panel_dreams(
2364 store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2365 params: &Value,
2366 response_id: Value,
2367) -> Value {
2368 let Some(store) = store else {
2369 return memory_panel_unavailable(response_id);
2370 };
2371 let limit =
2372 memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2373 let realms = match memory_panel_realms(store, params).await {
2374 Ok(realms) => realms,
2375 Err(err) => return memory_panel_store_error(response_id, err),
2376 };
2377 let mut runs = Vec::new();
2378 for realm in &realms {
2379 match store.dream_history(realm, limit).await {
2380 Ok(history) => runs.extend(history.into_iter().map(|run| (realm.clone(), run))),
2381 Err(err) => return memory_panel_store_error(response_id, err),
2382 }
2383 }
2384 runs.sort_by_key(|(_, run)| std::cmp::Reverse(run.last_op_at_ms));
2385 runs.truncate(limit);
2386 let rows: Vec<Value> = runs
2387 .iter()
2388 .map(|(realm, run)| {
2389 json!({
2390 "realm": realm,
2391 "run_id": run.run_id,
2392 "first_op_at_ms": run.first_op_at_ms,
2393 "last_op_at_ms": run.last_op_at_ms,
2394 "ops": run.ops,
2395 "op_kinds": run.op_kinds,
2396 "quarantined_ops": run.quarantined_ops,
2397 "memory_ids": run.memory_ids,
2398 "rationales": run.rationales,
2399 })
2400 })
2401 .collect();
2402 response_value(
2403 response_id,
2404 Some(json!({ "runs": rows, "realms": realms })),
2405 None,
2406 )
2407}
2408
2409async fn handle_memory_panel_dream_runs(
2413 store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2414 params: &Value,
2415 response_id: Value,
2416) -> Value {
2417 let Some(store) = store else {
2418 return memory_panel_unavailable(response_id);
2419 };
2420 let limit =
2421 memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2422 let realms = match memory_panel_realms(store, params).await {
2423 Ok(realms) => realms,
2424 Err(err) => return memory_panel_store_error(response_id, err),
2425 };
2426 let mut runs = Vec::new();
2427 for realm in &realms {
2428 match store.dream_runs(realm, limit).await {
2429 Ok(rows) => runs.extend(rows.into_iter().map(|run| (realm.clone(), run))),
2430 Err(err) => return memory_panel_store_error(response_id, err),
2431 }
2432 }
2433 runs.sort_by_key(|(_, run)| std::cmp::Reverse(run.completed_at_ms));
2434 runs.truncate(limit);
2435 let rows: Vec<Value> = runs
2436 .iter()
2437 .map(|(realm, run)| {
2438 let detail: Value =
2439 serde_json::from_str(&run.detail).unwrap_or_else(|_| json!(run.detail));
2440 json!({
2441 "realm": realm,
2442 "run_id": run.run_id,
2443 "partition": run.partition_label,
2444 "started_at_ms": run.started_at_ms,
2445 "completed_at_ms": run.completed_at_ms,
2446 "ops_committed": run.ops_committed,
2447 "detail": detail,
2448 })
2449 })
2450 .collect();
2451 response_value(
2452 response_id,
2453 Some(json!({ "runs": rows, "realms": realms })),
2454 None,
2455 )
2456}
2457
2458async fn handle_memory_panel_audit_verdicts(
2461 store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2462 params: &Value,
2463 response_id: Value,
2464) -> Value {
2465 let Some(store) = store else {
2466 return memory_panel_unavailable(response_id);
2467 };
2468 let limit =
2469 memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2470 let realms = match memory_panel_realms(store, params).await {
2471 Ok(realms) => realms,
2472 Err(err) => return memory_panel_store_error(response_id, err),
2473 };
2474 let mut verdicts = Vec::new();
2475 for realm in &realms {
2476 match store.open_dream_audit_verdicts(realm, limit).await {
2477 Ok(rows) => verdicts.extend(rows.into_iter().map(|row| (realm.clone(), row))),
2478 Err(err) => return memory_panel_store_error(response_id, err),
2479 }
2480 }
2481 verdicts.sort_by_key(|(_, row)| std::cmp::Reverse(row.created_at_ms));
2482 verdicts.truncate(limit);
2483 let rows: Vec<Value> = verdicts
2484 .iter()
2485 .map(|(realm, row)| {
2486 json!({
2487 "realm": realm,
2488 "run_id": row.run_id,
2489 "record_id": row.record_id,
2490 "verdict": row.verdict,
2491 "rationale": row.rationale,
2492 "created_at_ms": row.created_at_ms,
2493 })
2494 })
2495 .collect();
2496 response_value(
2497 response_id,
2498 Some(json!({ "verdicts": rows, "realms": realms })),
2499 None,
2500 )
2501}
2502
2503async fn handle_memory_panel_overview(
2507 store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2508 params: &Value,
2509 response_id: Value,
2510) -> Value {
2511 let Some(store) = store else {
2512 return memory_panel_unavailable(response_id);
2513 };
2514 let realms = match memory_panel_realms(store, params).await {
2515 Ok(realms) => realms,
2516 Err(err) => return memory_panel_store_error(response_id, err),
2517 };
2518 let (floor_records, floor_bytes) = store.scope_floors();
2519 let mut scopes = Vec::new();
2520 for realm in &realms {
2521 match store.scope_overview(realm).await {
2522 Ok(rows) => scopes.extend(rows.into_iter().map(|row| {
2523 json!({
2524 "realm": realm,
2525 "scope_kind": row.scope.kind_str(),
2526 "scope_key": row.scope.key(),
2527 "active": row.active,
2528 "quarantined": row.quarantined,
2529 "superseded": row.superseded,
2530 "tombstoned": row.tombstoned,
2531 "body_bytes": row.body_bytes,
2532 "floor_pressure": row.active as usize >= floor_records
2533 || row.body_bytes as usize >= floor_bytes,
2534 })
2535 })),
2536 Err(err) => return memory_panel_store_error(response_id, err),
2537 }
2538 }
2539 response_value(
2540 response_id,
2541 Some(json!({
2542 "scopes": scopes,
2543 "realms": realms,
2544 "floors": { "records": floor_records, "bytes": floor_bytes },
2545 })),
2546 None,
2547 )
2548}
2549
2550async fn handle_memory_panel_proposals(
2554 store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
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 proposals = Vec::new();
2568 for realm in &realms {
2569 match store.pending_proposals(realm, limit).await {
2570 Ok(rows) => proposals.extend(rows.into_iter().map(|row| {
2571 json!({
2572 "realm": realm,
2573 "proposal_id": row.proposal_id,
2574 "scope_kind": row.scope.kind_str(),
2575 "scope_key": row.scope.key(),
2576 "title": row.record.title,
2577 "kind": row.record.kind.as_str(),
2578 "author": format!("{:?}", row.author),
2579 "status": row.status,
2580 "created_at_ms": row.created_at_ms,
2581 "tainted": row.taint.is_some(),
2582 })
2583 })),
2584 Err(err) => return memory_panel_store_error(response_id, err),
2585 }
2586 }
2587 proposals.truncate(limit);
2588 response_value(
2589 response_id,
2590 Some(json!({ "proposals": proposals, "realms": realms })),
2591 None,
2592 )
2593}
2594
2595async fn handle_memory_panel_injections(
2598 store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2599 params: &Value,
2600 response_id: Value,
2601) -> Value {
2602 let Some(store) = store else {
2603 return memory_panel_unavailable(response_id);
2604 };
2605 let limit =
2606 memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2607 let realms = match memory_panel_realms(store, params).await {
2608 Ok(realms) => realms,
2609 Err(err) => return memory_panel_store_error(response_id, err),
2610 };
2611 let mut injections = Vec::new();
2612 for realm in &realms {
2613 match store.injection_log(realm, limit).await {
2614 Ok(rows) => injections.extend(rows.into_iter().map(|row| {
2615 json!({
2616 "realm": realm,
2617 "record_id": row.record_id,
2618 "identity": row.identity,
2619 "session_key": row.session_key,
2620 "surface": row.surface.as_str(),
2621 "at_ms": row.at_ms,
2622 })
2623 })),
2624 Err(err) => return memory_panel_store_error(response_id, err),
2625 }
2626 }
2627 injections.sort_by_key(|row| std::cmp::Reverse(row["at_ms"].as_u64().unwrap_or(0)));
2628 injections.truncate(limit);
2629 response_value(
2630 response_id,
2631 Some(json!({ "injections": injections, "realms": realms })),
2632 None,
2633 )
2634}
2635
2636async fn handle_memory_panel_harvests(
2638 store: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
2639 params: &Value,
2640 response_id: Value,
2641) -> Value {
2642 let Some(store) = store else {
2643 return memory_panel_unavailable(response_id);
2644 };
2645 let limit =
2646 memory_panel_limit_param(params, MEMORY_PANEL_DREAMS_DEFAULT, MEMORY_PANEL_DREAMS_MAX);
2647 let realms = match memory_panel_realms(store, params).await {
2648 Ok(realms) => realms,
2649 Err(err) => return memory_panel_store_error(response_id, err),
2650 };
2651 let mut harvests = Vec::new();
2652 for realm in &realms {
2653 match store.pending_harvests(realm, limit).await {
2654 Ok(rows) => harvests.extend(rows.into_iter().map(|row| {
2655 json!({
2656 "realm": realm,
2657 "identity": row.identity,
2658 "session_key": row.session_key,
2659 "cause": row.cause,
2660 "retired_at_ms": row.retired_at_ms,
2661 })
2662 })),
2663 Err(err) => return memory_panel_store_error(response_id, err),
2664 }
2665 }
2666 harvests.truncate(limit);
2667 response_value(
2668 response_id,
2669 Some(json!({ "harvests": harvests, "realms": realms })),
2670 None,
2671 )
2672}
2673
2674fn access_status_result(access: Option<&AccessController>, view: Option<&AccessView>) -> Value {
2675 match (access, view) {
2676 (Some(controller), Some(view)) => {
2677 let (_, revision) = controller.snapshot();
2678 json!({
2679 "available": true,
2680 "enabled": view.enforced(),
2681 "revision": revision,
2682 "subject": view.subject(),
2683 "groups": view.groups().iter().collect::<Vec<_>>(),
2684 "is_admin": view.is_admin(),
2685 "can_administer": view.can_administer(),
2686 "actions": ACCESS_ACTIONS,
2687 })
2688 }
2689 _ => json!({
2690 "available": false,
2691 "enabled": false,
2692 "actions": ACCESS_ACTIONS,
2693 }),
2694 }
2695}
2696
2697fn handle_access_admin_rpc(
2700 access: Option<&AccessController>,
2701 view: Option<&AccessView>,
2702 request: &JsonRpcRequest,
2703) -> Option<Value> {
2704 if !request.method.starts_with("mobkit/access/") {
2705 return None;
2706 }
2707 let response_id = request.id.clone().unwrap_or(Value::Null);
2708 let controller = match request.method.as_str() {
2709 "mobkit/access/status" => {
2710 return Some(response_value(
2711 response_id,
2712 Some(access_status_result(access, view)),
2713 None,
2714 ));
2715 }
2716 _ => {
2717 let Some(controller) = access else {
2718 return Some(access_unavailable_rpc_error(response_id));
2719 };
2720 if !view.is_some_and(AccessView::can_administer) {
2721 return Some(access_denied_rpc_error(
2722 response_id,
2723 "access denied: access.admin",
2724 ));
2725 }
2726 controller
2727 }
2728 };
2729 let result = match request.method.as_str() {
2730 "mobkit/access/get" => {
2731 let (config, revision) = controller.snapshot();
2732 Ok(json!({ "config": &*config, "revision": revision }))
2733 }
2734 "mobkit/access/set" => {
2735 match serde_json::from_value(request.params.get("config").cloned().unwrap_or_default())
2736 {
2737 Ok(config) => controller
2738 .replace_config(config)
2739 .map(|revision| json!({ "revision": revision })),
2740 Err(err) => {
2741 return Some(invalid_params(
2742 response_id,
2743 format!("invalid access config: {err}"),
2744 ));
2745 }
2746 }
2747 }
2748 "mobkit/access/rules/upsert" => {
2749 match serde_json::from_value::<AccessRule>(
2750 request.params.get("rule").cloned().unwrap_or_default(),
2751 ) {
2752 Ok(rule) => controller
2753 .upsert_rule(rule)
2754 .map(|revision| json!({ "revision": revision })),
2755 Err(err) => {
2756 return Some(invalid_params(
2757 response_id,
2758 format!("invalid access rule: {err}"),
2759 ));
2760 }
2761 }
2762 }
2763 "mobkit/access/rules/delete" => {
2764 let Some(rule_id) = request.params.get("id").and_then(Value::as_str) else {
2765 return Some(invalid_params(response_id, "id required"));
2766 };
2767 controller
2768 .delete_rule(rule_id)
2769 .map(|revision| json!({ "revision": revision }))
2770 }
2771 "mobkit/access/groups/set" => {
2772 let Some(name) = request.params.get("name").and_then(Value::as_str) else {
2773 return Some(invalid_params(response_id, "name required"));
2774 };
2775 match serde_json::from_value::<AccessGroup>(
2776 request.params.get("group").cloned().unwrap_or_default(),
2777 ) {
2778 Ok(group) => controller
2779 .set_group(name, group)
2780 .map(|revision| json!({ "revision": revision })),
2781 Err(err) => {
2782 return Some(invalid_params(
2783 response_id,
2784 format!("invalid access group: {err}"),
2785 ));
2786 }
2787 }
2788 }
2789 "mobkit/access/groups/delete" => {
2790 let Some(name) = request.params.get("name").and_then(Value::as_str) else {
2791 return Some(invalid_params(response_id, "name required"));
2792 };
2793 controller
2794 .delete_group(name)
2795 .map(|revision| json!({ "revision": revision }))
2796 }
2797 "mobkit/access/enable" => {
2798 let Some(enabled) = request.params.get("enabled").and_then(Value::as_bool) else {
2799 return Some(invalid_params(response_id, "enabled (bool) required"));
2800 };
2801 controller
2802 .set_enabled(enabled)
2803 .map(|revision| json!({ "revision": revision }))
2804 }
2805 "mobkit/access/preview" => {
2806 let subject = request.params.get("subject").and_then(Value::as_str);
2807 let Some(action) = request.params.get("action").and_then(Value::as_str) else {
2808 return Some(invalid_params(response_id, "action required"));
2809 };
2810 let identity = request.params.get("identity").and_then(Value::as_str);
2811 let preview_view = controller.view_for_subject(subject);
2812 let decision = match identity {
2813 Some(identity) => preview_view.decide_agent(action, identity),
2814 None => preview_view.decide(action, &AccessResource::none()),
2815 };
2816 Ok(json!({
2817 "subject": subject,
2818 "action": action,
2819 "identity": identity,
2820 "allowed": decision.is_allow(),
2821 "reason": decision.reason(),
2822 "groups": preview_view.groups().iter().collect::<Vec<_>>(),
2823 "is_admin": preview_view.is_admin(),
2824 }))
2825 }
2826 _ => {
2827 return Some(response_value(
2828 response_id,
2829 None,
2830 Some(JsonRpcError {
2831 code: -32601,
2832 message: "Method not found".to_string(),
2833 data: None,
2834 }),
2835 ));
2836 }
2837 };
2838 Some(match result {
2839 Ok(value) => response_value(response_id, Some(value), None),
2840 Err(err) => access_config_rpc_error(response_id, err),
2841 })
2842}
2843
2844fn cursor_is_after(candidate: &ConsoleCursor, current: &ConsoleCursor) -> bool {
2845 match (candidate.seq(), current.seq()) {
2846 (Some(candidate), Some(current)) => candidate > current,
2847 _ => candidate > current,
2848 }
2849}
2850
2851fn sse_event_from_timeline_event(event: &ConsoleTimelineEvent) -> Option<Event> {
2852 let (event_name, id) = match event {
2853 ConsoleTimelineEvent::SnapshotStarted { .. } => ("snapshot_started", None),
2854 ConsoleTimelineEvent::ConsoleFrame { frame } => (
2855 if frame.kind == "frame_updated" {
2856 "frame_updated"
2857 } else {
2858 "console_frame"
2859 },
2860 Some(frame.cursor.to_string()),
2861 ),
2862 ConsoleTimelineEvent::FrameUpdated { frame } => {
2863 ("frame_updated", Some(frame.cursor.to_string()))
2864 }
2865 ConsoleTimelineEvent::SnapshotComplete { cursor } => (
2866 "snapshot_complete",
2867 cursor.as_ref().map(ToString::to_string),
2868 ),
2869 ConsoleTimelineEvent::ReplayUnavailable { .. } => ("replay_unavailable", None),
2870 };
2871 let data = match serde_json::to_string(event) {
2872 Ok(value) => value,
2873 Err(_) => return None,
2874 };
2875 let mut sse = Event::default().event(event_name).data(data);
2876 if let Some(id) = id {
2877 sse = sse.id(id);
2878 }
2879 Some(sse)
2880}
2881
2882pub async fn console_rpc_multipart_handler(
2883 State(state): State<ConsoleJsonState>,
2884 headers: HeaderMap,
2885 uri: Uri,
2886 mut multipart: Multipart,
2887) -> impl IntoResponse {
2888 let auth_context = match console_request_auth_context(&state, &headers, &uri) {
2889 Some(context) => context,
2890 None => {
2891 return (
2892 StatusCode::UNAUTHORIZED,
2893 Json::<Value>(serde_json::json!({
2894 "jsonrpc": JSONRPC_VERSION,
2895 "id": Value::Null,
2896 "error": {
2897 "code": -32600,
2898 "message": "unauthorized: console rpc requires a valid auth token",
2899 }
2900 })),
2901 );
2902 }
2903 };
2904
2905 let mut payload: Option<String> = None;
2906 let mut files: std::collections::BTreeMap<String, MultipartImageUpload> =
2907 std::collections::BTreeMap::new();
2908
2909 while let Some(mut field) = match multipart.next_field().await {
2910 Ok(field) => field,
2911 Err(err) => {
2912 return (
2913 StatusCode::BAD_REQUEST,
2914 Json::<Value>(json_rpc_error_value(
2915 Value::Null,
2916 -32602,
2917 format!("invalid multipart body: {err}"),
2918 )),
2919 );
2920 }
2921 } {
2922 let name = field.name().unwrap_or("").to_string();
2923 if name == "payload" {
2924 if payload.is_some() {
2925 return (
2926 StatusCode::BAD_REQUEST,
2927 Json::<Value>(json_rpc_error_value(
2928 Value::Null,
2929 -32602,
2930 "duplicate payload part",
2931 )),
2932 );
2933 }
2934 payload = match field.text().await {
2935 Ok(text) => Some(text),
2936 Err(err) => {
2937 return (
2938 StatusCode::BAD_REQUEST,
2939 Json::<Value>(json_rpc_error_value(
2940 Value::Null,
2941 -32602,
2942 format!("invalid payload part: {err}"),
2943 )),
2944 );
2945 }
2946 };
2947 continue;
2948 }
2949
2950 let Some(upload_id) = name.strip_prefix("file:").filter(|id| !id.is_empty()) else {
2951 return (
2952 StatusCode::BAD_REQUEST,
2953 Json::<Value>(json_rpc_error_value(
2954 Value::Null,
2955 -32602,
2956 format!("unexpected multipart field: {name}"),
2957 )),
2958 );
2959 };
2960 if files.len() >= MAX_MULTIPART_IMAGES {
2961 return (
2962 StatusCode::BAD_REQUEST,
2963 Json::<Value>(json_rpc_error_value(
2964 Value::Null,
2965 -32602,
2966 format!("too many image attachments; max {MAX_MULTIPART_IMAGES}"),
2967 )),
2968 );
2969 }
2970 if files.contains_key(upload_id) {
2971 return (
2972 StatusCode::BAD_REQUEST,
2973 Json::<Value>(json_rpc_error_value(
2974 Value::Null,
2975 -32602,
2976 format!("duplicate file part for upload_id {upload_id}"),
2977 )),
2978 );
2979 }
2980 let media_type = field
2981 .content_type()
2982 .map(str::to_string)
2983 .unwrap_or_else(|| "application/octet-stream".to_string());
2984 if !is_allowed_image_media_type(&media_type) {
2985 return (
2986 StatusCode::BAD_REQUEST,
2987 Json::<Value>(json_rpc_error_value(
2988 Value::Null,
2989 -32602,
2990 format!("unsupported image media type: {media_type}"),
2991 )),
2992 );
2993 }
2994 let mut bytes = bytes::BytesMut::new();
2995 loop {
2996 let chunk = match field.chunk().await {
2997 Ok(chunk) => chunk,
2998 Err(err) => {
2999 return (
3000 StatusCode::BAD_REQUEST,
3001 Json::<Value>(json_rpc_error_value(
3002 Value::Null,
3003 -32602,
3004 format!("invalid file part {upload_id}: {err}"),
3005 )),
3006 );
3007 }
3008 };
3009 let Some(chunk) = chunk else {
3010 break;
3011 };
3012 if bytes.len() + chunk.len() > MAX_MULTIPART_IMAGE_BYTES {
3013 return (
3014 StatusCode::BAD_REQUEST,
3015 Json::<Value>(json_rpc_error_value(
3016 Value::Null,
3017 -32602,
3018 format!("image attachment {upload_id} exceeds 25 MiB"),
3019 )),
3020 );
3021 }
3022 bytes.extend_from_slice(&chunk);
3023 }
3024 files.insert(
3025 upload_id.to_string(),
3026 MultipartImageUpload {
3027 media_type,
3028 bytes: bytes.freeze(),
3029 },
3030 );
3031 }
3032
3033 let payload = match payload {
3034 Some(payload) => payload,
3035 None => {
3036 return (
3037 StatusCode::BAD_REQUEST,
3038 Json::<Value>(json_rpc_error_value(
3039 Value::Null,
3040 -32602,
3041 "payload part required",
3042 )),
3043 );
3044 }
3045 };
3046 let mut parsed_request = match serde_json::from_str::<JsonRpcRequest>(&payload) {
3047 Ok(req) => req,
3048 Err(err) => {
3049 return (
3050 StatusCode::OK,
3051 Json::<Value>(json_rpc_error_value(
3052 Value::Null,
3053 -32600,
3054 format!("Invalid Request: {err}"),
3055 )),
3056 );
3057 }
3058 };
3059 let response_id = parsed_request.id.clone().unwrap_or(Value::Null);
3060 if let Err(message) =
3061 crate::member_comms_id::validate_public_rpc_member_aliases(&parsed_request.params)
3062 {
3063 return (
3064 StatusCode::OK,
3065 Json::<Value>(invalid_params(response_id, message)),
3066 );
3067 }
3068 if state.decisions.console.read_only && is_console_mutating_rpc_method(&parsed_request.method) {
3069 return (
3070 StatusCode::OK,
3071 Json::<Value>(console_read_only_rpc_error(response_id)),
3072 );
3073 }
3074 match parsed_request.method.as_str() {
3075 "mobkit/console/send" => {
3076 let Some(aggregator) = &state.console_aggregator else {
3077 return (
3078 StatusCode::OK,
3079 Json::<Value>(invalid_params(
3080 response_id,
3081 "mobkit/console/send multipart requires a console aggregator",
3082 )),
3083 );
3084 };
3085 let Some(identity) = parsed_request
3086 .params
3087 .get("identity")
3088 .and_then(Value::as_str)
3089 else {
3090 return (
3091 StatusCode::OK,
3092 Json::<Value>(invalid_params(response_id, "identity required")),
3093 );
3094 };
3095 if let Some(runtime) = &state.runtime
3103 && let Some(controller) = state.access.as_ref().filter(|c| c.enabled())
3104 {
3105 prime_access_cache_from_runtime(runtime, controller).await;
3106 }
3107 if let Some(violation) = console_rpc_access_violation(
3108 auth_context.access_view.as_ref(),
3109 "mobkit/console/send",
3110 &parsed_request.params,
3111 ) {
3112 return (
3113 StatusCode::OK,
3114 Json::<Value>(response_value(response_id, None, Some(violation))),
3115 );
3116 }
3117 let binary_blob_store =
3118 match Box::pin(aggregator.binary_blob_store_for_identity(identity)).await {
3119 Ok(Some(store)) => store,
3120 Ok(None) => {
3121 return (
3122 StatusCode::OK,
3123 Json::<Value>(invalid_params(
3124 response_id,
3125 "binary blob store unavailable for identity",
3126 )),
3127 );
3128 }
3129 Err(err) => {
3130 return (
3131 StatusCode::OK,
3132 Json::<Value>(console_send_rpc_error(response_id, err)),
3133 );
3134 }
3135 };
3136 if let Err(message) = externalize_image_upload_placeholders(
3137 &mut parsed_request.params,
3138 files,
3139 binary_blob_store,
3140 )
3141 .await
3142 {
3143 return (
3144 StatusCode::OK,
3145 Json::<Value>(invalid_params(response_id, message)),
3146 );
3147 }
3148 }
3149 "mobkit/blob/upload" => {
3150 let Some(runtime) = &state.runtime else {
3151 return (
3152 StatusCode::NOT_FOUND,
3153 Json::<Value>(json_rpc_error_value(
3154 response_id,
3155 -32600,
3156 "mobkit/blob/upload multipart requires a unified runtime",
3157 )),
3158 );
3159 };
3160 let Some(binary_blob_store) = runtime.binary_blob_store() else {
3161 return (
3162 StatusCode::INTERNAL_SERVER_ERROR,
3163 Json::<Value>(json_rpc_error_value(
3164 response_id,
3165 -32000,
3166 "binary blob store unavailable",
3167 )),
3168 );
3169 };
3170 let result = match externalize_single_image_upload(
3171 &parsed_request.params,
3172 files,
3173 binary_blob_store,
3174 )
3175 .await
3176 {
3177 Ok(result) => result,
3178 Err(message) => {
3179 return (
3180 StatusCode::OK,
3181 Json::<Value>(invalid_params(response_id, message)),
3182 );
3183 }
3184 };
3185 return (
3186 StatusCode::OK,
3187 Json::<Value>(response_value(response_id, Some(result), None)),
3188 );
3189 }
3190 _ => {
3191 return (
3192 StatusCode::OK,
3193 Json::<Value>(invalid_params(
3194 response_id,
3195 "multipart RPC supports mobkit/console/send and mobkit/blob/upload only",
3196 )),
3197 );
3198 }
3199 }
3200 let response_value =
3201 if parsed_request.method == "mobkit/console/send" && state.runtime.is_none() {
3202 Box::pin(handle_console_aggregator_rpc(
3203 state.console_aggregator.clone(),
3204 parsed_request,
3205 true,
3206 state.decisions.console.read_only,
3207 state.access.as_ref(),
3208 auth_context.access_view.as_ref(),
3209 ))
3210 .await
3211 } else {
3212 let Some(runtime) = &state.runtime else {
3213 return (
3214 StatusCode::NOT_FOUND,
3215 Json::<Value>(json_rpc_error_value(
3216 response_id,
3217 -32600,
3218 "console rpc multipart requires a unified runtime",
3219 )),
3220 );
3221 };
3222 Box::pin(handle_console_runtime_rpc_with_visibility(
3223 runtime,
3224 state.module_runtime.clone(),
3225 state.contact_directory.as_ref(),
3226 state.gateway_peer_keys.as_ref(),
3227 state.console_events.clone(),
3228 state.console_aggregator.clone(),
3229 state.identity_runtime.clone(),
3230 state.metadata_table.clone(),
3231 state.mob_events.clone(),
3232 state.visibility_policy.as_ref(),
3233 parsed_request,
3234 true,
3235 state.decisions.console.read_only,
3236 auth_context.principal.as_deref(),
3237 state.access.as_ref(),
3238 auth_context.access_view.as_ref(),
3239 state.memory_panel.as_deref(),
3240 state.identity_roster.clone(),
3241 state.workgraph.as_ref(),
3242 state.topology.as_ref(),
3243 ))
3244 .await
3245 };
3246 (StatusCode::OK, Json::<Value>(response_value))
3247}
3248
3249pub async fn blob_get_handler(
3250 State(state): State<ConsoleJsonState>,
3251 headers: HeaderMap,
3252 uri: Uri,
3253 AxumPath(blob_id): AxumPath<String>,
3254) -> impl IntoResponse {
3255 if !console_request_authorized(&state, &headers, &uri) {
3256 return (
3257 StatusCode::UNAUTHORIZED,
3258 Json::<Value>(serde_json::json!({ "error": "unauthorized" })),
3259 )
3260 .into_response();
3261 }
3262 if !is_valid_blob_id_value(&blob_id) {
3263 return (
3264 StatusCode::BAD_REQUEST,
3265 Json::<Value>(serde_json::json!({ "error": "invalid_blob_id" })),
3266 )
3267 .into_response();
3268 }
3269 let blob_id = meerkat_core::BlobId::from(blob_id.as_str());
3270 let mut stores: Vec<std::sync::Arc<dyn BinaryBlobStore>> = Vec::new();
3271 if let Some(runtime) = &state.runtime
3272 && let Some(store) = runtime.binary_blob_store()
3273 {
3274 stores.push(store);
3275 }
3276 if let Some(aggregator) = &state.console_aggregator {
3277 stores.extend(aggregator.binary_blob_stores());
3278 }
3279 if stores.is_empty() {
3280 return (
3281 StatusCode::NOT_FOUND,
3282 Json::<Value>(serde_json::json!({ "error": "blob_store_unavailable" })),
3283 )
3284 .into_response();
3285 }
3286 for store in stores {
3287 match store.get_bytes(&blob_id).await {
3288 Ok(payload) => return blob_payload_response(payload),
3289 Err(meerkat_core::BlobStoreError::NotFound(_)) => continue,
3290 Err(err) => {
3291 return (
3292 StatusCode::INTERNAL_SERVER_ERROR,
3293 Json::<Value>(serde_json::json!({ "error": err.to_string() })),
3294 )
3295 .into_response();
3296 }
3297 }
3298 }
3299 (
3300 StatusCode::NOT_FOUND,
3301 Json::<Value>(serde_json::json!({ "error": "blob_not_found" })),
3302 )
3303 .into_response()
3304}
3305
3306fn blob_payload_response(payload: BinaryBlobPayload) -> axum::response::Response {
3307 let mut response_headers = HeaderMap::new();
3308 let content_type = HeaderValue::from_str(&payload.media_type)
3309 .unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
3310 response_headers.insert(header::CONTENT_TYPE, content_type);
3311 if let Ok(content_length) = HeaderValue::from_str(&payload.size.to_string()) {
3312 response_headers.insert(header::CONTENT_LENGTH, content_length);
3313 }
3314 response_headers.insert(
3315 header::CACHE_CONTROL,
3316 HeaderValue::from_static("private, max-age=31536000, immutable"),
3317 );
3318 (StatusCode::OK, response_headers, payload.data).into_response()
3319}
3320
3321fn console_request_authorized(state: &ConsoleJsonState, headers: &HeaderMap, uri: &Uri) -> bool {
3322 console_request_auth_context(state, headers, uri).is_some()
3323}
3324
3325fn console_request_auth_context(
3326 state: &ConsoleJsonState,
3327 headers: &HeaderMap,
3328 uri: &Uri,
3329) -> Option<ConsoleHttpAuthContext> {
3330 if !state.decisions.console.require_app_auth {
3331 let principal = state.access.as_ref().and_then(|_| {
3334 let token = console_request_token(headers, uri)?;
3335 resolve_authorized_console_auth_from_token(&state.decisions, &token)
3336 .map(|auth| auth.email)
3337 });
3338 return Some(ConsoleHttpAuthContext {
3339 access_view: state
3340 .access
3341 .as_ref()
3342 .map(|controller| controller.view_for_subject(principal.as_deref())),
3343 principal,
3344 });
3345 }
3346 let token = console_request_token(headers, uri)?;
3347 let auth = resolve_authorized_console_auth_from_token(&state.decisions, &token)?;
3348 Some(ConsoleHttpAuthContext {
3349 access_view: state
3350 .access
3351 .as_ref()
3352 .map(|controller| controller.view_for_subject(Some(auth.email.as_str()))),
3353 principal: Some(auth.email),
3354 })
3355}
3356
3357fn console_request_token(headers: &HeaderMap, uri: &Uri) -> Option<String> {
3358 let bearer_token = headers
3359 .get(header::AUTHORIZATION)
3360 .and_then(|v| v.to_str().ok())
3361 .and_then(extract_bearer_token_from_header)
3362 .map(String::from);
3363 let query_token = uri.query().and_then(|q| {
3366 form_urlencoded::parse(q.as_bytes())
3367 .find(|(key, _)| key == "auth_token")
3368 .map(|(_, value)| value.into_owned())
3369 });
3370 bearer_token.or(query_token)
3371}
3372
3373#[derive(Debug)]
3374struct MultipartImageUpload {
3375 media_type: String,
3376 bytes: bytes::Bytes,
3377}
3378
3379fn json_rpc_error_value(id: Value, code: i64, message: impl Into<String>) -> Value {
3380 serde_json::json!({
3381 "jsonrpc": JSONRPC_VERSION,
3382 "id": id,
3383 "error": {
3384 "code": code,
3385 "message": message.into(),
3386 }
3387 })
3388}
3389
3390fn is_allowed_image_media_type(media_type: &str) -> bool {
3391 matches!(
3392 media_type,
3393 "image/png" | "image/jpeg" | "image/webp" | "image/gif"
3394 )
3395}
3396
3397fn image_upload_part_name<'a>(
3398 object: &'a serde_json::Map<String, Value>,
3399 context: &str,
3400) -> Result<&'a str, String> {
3401 object
3402 .get("upload_id")
3403 .or_else(|| object.get("part_name"))
3404 .and_then(Value::as_str)
3405 .map(str::trim)
3406 .filter(|value| !value.is_empty())
3407 .ok_or_else(|| format!("{context}.upload_id or {context}.part_name is required"))
3408}
3409
3410async fn externalize_image_upload_placeholders(
3411 params: &mut Value,
3412 files: std::collections::BTreeMap<String, MultipartImageUpload>,
3413 blob_store: std::sync::Arc<dyn crate::blob_store::BinaryBlobStore>,
3414) -> Result<(), String> {
3415 let Some(content) = params.get_mut("content") else {
3416 return Err("multipart payload params.content is required".to_string());
3417 };
3418 let mut placeholders = std::collections::BTreeMap::<String, String>::new();
3419 collect_image_upload_placeholders(content, &mut placeholders)?;
3420 if placeholders.is_empty() {
3421 return Err(
3422 "multipart payload must contain at least one image_upload placeholder".to_string(),
3423 );
3424 }
3425 if placeholders.len() > MAX_MULTIPART_IMAGES {
3426 return Err(format!(
3427 "too many image_upload placeholders; max {MAX_MULTIPART_IMAGES}"
3428 ));
3429 }
3430 for upload_id in files.keys() {
3431 if !placeholders.contains_key(upload_id) {
3432 return Err(format!(
3433 "file part has no matching image_upload placeholder: {upload_id}"
3434 ));
3435 }
3436 }
3437 for upload_id in placeholders.keys() {
3438 if !files.contains_key(upload_id) {
3439 return Err(format!(
3440 "image_upload placeholder missing file part: {upload_id}"
3441 ));
3442 }
3443 }
3444
3445 let mut refs = std::collections::BTreeMap::<String, Value>::new();
3446 for (upload_id, file) in files {
3447 let declared_media_type = placeholders
3448 .get(&upload_id)
3449 .cloned()
3450 .unwrap_or_else(|| file.media_type.clone());
3451 if !is_allowed_image_media_type(&declared_media_type) {
3452 return Err(format!(
3453 "unsupported image media type in placeholder {upload_id}: {declared_media_type}"
3454 ));
3455 }
3456 if declared_media_type != file.media_type {
3457 return Err(format!(
3458 "media type mismatch for {upload_id}: placeholder {declared_media_type}, file {}",
3459 file.media_type
3460 ));
3461 }
3462 let blob_ref = blob_store
3463 .put_bytes(&file.media_type, file.bytes)
3464 .await
3465 .map_err(|err| format!("failed to store image {upload_id}: {err}"))?;
3466 refs.insert(
3467 upload_id,
3468 serde_json::json!({
3469 "type": "image",
3470 "media_type": blob_ref.media_type,
3471 "source": "blob",
3472 "blob_id": blob_ref.blob_id,
3473 }),
3474 );
3475 }
3476 replace_image_upload_placeholders(content, &refs)?;
3477 if let Some(object) = params.as_object_mut() {
3478 object.remove("message");
3479 }
3480 Ok(())
3481}
3482
3483async fn externalize_single_image_upload(
3484 params: &Value,
3485 files: std::collections::BTreeMap<String, MultipartImageUpload>,
3486 blob_store: std::sync::Arc<dyn crate::blob_store::BinaryBlobStore>,
3487) -> Result<Value, String> {
3488 let upload = params.get("upload").unwrap_or(params);
3489 if upload
3490 .get("type")
3491 .and_then(Value::as_str)
3492 .is_some_and(|kind| kind != "image_upload")
3493 {
3494 return Err("upload.type must be image_upload".to_string());
3495 }
3496 let upload_object = upload
3497 .as_object()
3498 .ok_or_else(|| "upload must be an object".to_string())?;
3499 let upload_id = image_upload_part_name(upload_object, "upload")?;
3500 let Some(file) = files.get(upload_id) else {
3501 return Err(format!(
3502 "image_upload placeholder missing file part: {upload_id}"
3503 ));
3504 };
3505 if files.len() != 1 {
3506 return Err("mobkit/blob/upload accepts exactly one file part".to_string());
3507 }
3508 let declared_media_type = upload
3509 .get("media_type")
3510 .and_then(Value::as_str)
3511 .unwrap_or(file.media_type.as_str());
3512 if !is_allowed_image_media_type(declared_media_type) {
3513 return Err(format!(
3514 "unsupported image media type in upload {upload_id}: {declared_media_type}"
3515 ));
3516 }
3517 if declared_media_type != file.media_type {
3518 return Err(format!(
3519 "media type mismatch for {upload_id}: placeholder {declared_media_type}, file {}",
3520 file.media_type
3521 ));
3522 }
3523 let size = file.bytes.len() as u64;
3524 let blob_ref = blob_store
3525 .put_bytes(&file.media_type, file.bytes.clone())
3526 .await
3527 .map_err(|err| format!("failed to store image {upload_id}: {err}"))?;
3528 Ok(json!({
3529 "blob_id": blob_ref.blob_id,
3530 "media_type": blob_ref.media_type,
3531 "size": size,
3532 }))
3533}
3534
3535fn collect_image_upload_placeholders(
3536 value: &Value,
3537 placeholders: &mut std::collections::BTreeMap<String, String>,
3538) -> Result<(), String> {
3539 match value {
3540 Value::Array(items) => {
3541 for item in items {
3542 collect_image_upload_placeholders(item, placeholders)?;
3543 }
3544 }
3545 Value::Object(object) => {
3546 if object.get("type").and_then(Value::as_str) == Some("image_upload") {
3547 let upload_id = image_upload_part_name(object, "image_upload")?;
3548 let media_type = object
3549 .get("media_type")
3550 .and_then(Value::as_str)
3551 .map(str::trim)
3552 .filter(|value| !value.is_empty())
3553 .ok_or_else(|| format!("image_upload {upload_id} requires media_type"))?;
3554 if placeholders
3555 .insert(upload_id.to_string(), media_type.to_string())
3556 .is_some()
3557 {
3558 return Err(format!("duplicate image_upload placeholder: {upload_id}"));
3559 }
3560 } else {
3561 for child in object.values() {
3562 collect_image_upload_placeholders(child, placeholders)?;
3563 }
3564 }
3565 }
3566 _ => {}
3567 }
3568 Ok(())
3569}
3570
3571fn replace_image_upload_placeholders(
3572 value: &mut Value,
3573 refs: &std::collections::BTreeMap<String, Value>,
3574) -> Result<(), String> {
3575 match value {
3576 Value::Array(items) => {
3577 for item in items {
3578 replace_image_upload_placeholders(item, refs)?;
3579 }
3580 }
3581 Value::Object(object) => {
3582 if object.get("type").and_then(Value::as_str) == Some("image_upload") {
3583 let upload_id = image_upload_part_name(object, "image_upload")?;
3584 let replacement = refs
3585 .get(upload_id)
3586 .ok_or_else(|| format!("missing blob replacement for {upload_id}"))?;
3587 *value = replacement.clone();
3588 } else {
3589 for child in object.values_mut() {
3590 replace_image_upload_placeholders(child, refs)?;
3591 }
3592 }
3593 }
3594 _ => {}
3595 }
3596 Ok(())
3597}
3598
3599fn response_value(id: Value, result: Option<Value>, error: Option<JsonRpcError>) -> Value {
3600 serde_json::to_value(JsonRpcResponse {
3601 jsonrpc: JSONRPC_VERSION.to_string(),
3602 id,
3603 result,
3604 error,
3605 })
3606 .unwrap_or_else(|_| {
3607 serde_json::json!({
3608 "jsonrpc": JSONRPC_VERSION,
3609 "id": Value::Null,
3610 "error": {
3611 "code": -32603,
3612 "message": "serialization failed",
3613 }
3614 })
3615 })
3616}
3617
3618fn invalid_params(id: Value, message: impl Into<String>) -> Value {
3619 response_value(
3620 id,
3621 None,
3622 Some(JsonRpcError {
3623 code: -32602,
3624 message: message.into(),
3625 data: None,
3626 }),
3627 )
3628}
3629
3630fn gating_decision_failed_error(id: Value, err: impl std::fmt::Display) -> Value {
3631 tracing::warn!(
3632 target: "mobkit::console",
3633 error = %err,
3634 "console gating decision failed"
3635 );
3636 invalid_params(id, "gating decision failed")
3637}
3638
3639fn runtime_binding_from_wire(
3640 binding: WireRuntimeBinding,
3641) -> Result<meerkat_mob::RuntimeBinding, String> {
3642 match binding {
3643 WireRuntimeBinding::Session => Ok(meerkat_mob::RuntimeBinding::Session),
3644 WireRuntimeBinding::External {
3645 address,
3646 bootstrap_token,
3647 identity,
3648 } => {
3649 let resolved = identity.resolve().map_err(|err| err.to_string())?;
3650 Ok(meerkat_mob::RuntimeBinding::External {
3651 peer_id: resolved.peer_id.to_string(),
3652 address,
3653 bootstrap_token,
3654 pubkey: resolved.pubkey,
3655 })
3656 }
3657 }
3658}
3659
3660fn parse_optional_runtime_mode(params: &Value) -> Result<Option<MobRuntimeMode>, String> {
3661 match params.get("runtime_mode") {
3662 None | Some(Value::Null) => Ok(None),
3663 Some(value) => serde_json::from_value::<MobRuntimeMode>(value.clone())
3664 .map(Some)
3665 .map_err(|err| {
3666 format!("runtime_mode must be \"autonomous_host\" or \"turn_driven\": {err}")
3667 }),
3668 }
3669}
3670
3671fn parse_optional_backend(params: &Value) -> Result<Option<MobBackendKind>, String> {
3672 match params.get("backend") {
3673 None | Some(Value::Null) => Ok(None),
3674 Some(value) => serde_json::from_value::<MobBackendKind>(value.clone())
3675 .map(Some)
3676 .map_err(|err| format!("backend must be \"session\" or \"external\": {err}")),
3677 }
3678}
3679
3680fn parse_optional_runtime_binding(
3681 params: &Value,
3682) -> Result<Option<meerkat_mob::RuntimeBinding>, String> {
3683 match params.get("binding") {
3684 None | Some(Value::Null) => Ok(None),
3685 Some(value) => {
3686 let wire = serde_json::from_value::<WireRuntimeBinding>(value.clone())
3687 .map_err(|err| format!("binding: {err}"))?;
3688 runtime_binding_from_wire(wire).map(Some)
3689 }
3690 }
3691}
3692
3693async fn member_entry_to_console_json(
3694 runtime: &MobRuntime,
3695 entry: &meerkat_mob::runtime::MobMemberListEntry,
3696) -> Value {
3697 let mut value = member_entry_to_json(entry);
3698 if let Some(object) = value.as_object_mut() {
3699 object.insert(
3700 "model_capabilities".to_string(),
3701 serde_json::to_value(model_capabilities_for_member_entry(
3702 runtime.handle().definition(),
3703 entry,
3704 ))
3705 .unwrap_or(Value::Null),
3706 );
3707 }
3708 value
3709}
3710
3711fn internal_error(id: Value, message: impl Into<String>) -> Value {
3712 let message = message.into();
3713 tracing::warn!(
3714 target: "mobkit::console",
3715 error = %message,
3716 "console JSON-RPC internal error"
3717 );
3718 response_value(
3725 id,
3726 None,
3727 Some(JsonRpcError {
3728 code: -32000,
3729 message: message.clone(),
3730 data: Some(json!({ "error": "internal_error", "detail": message })),
3731 }),
3732 )
3733}
3734
3735fn stale_event_cursor_response(id: Value, after_cursor: u64, latest_cursor: u64) -> Value {
3740 response_value(
3741 id,
3742 None,
3743 Some(JsonRpcError {
3744 code: crate::rpc::MOB_EVENTS_STALE_CURSOR_CODE,
3745 message: format!(
3746 "stale mob event cursor: requested {after_cursor}, latest {latest_cursor}"
3747 ),
3748 data: Some(serde_json::json!({
3749 "error": "event_query_stale",
3750 "after_cursor": after_cursor,
3751 "latest_cursor": latest_cursor,
3752 })),
3753 }),
3754 )
3755}
3756
3757fn console_timeline_replay_unavailable_response(
3758 id: Value,
3759 err: ConsoleLogError,
3760 requested_cursor: Option<&ConsoleCursor>,
3761 latest_cursor: Option<ConsoleCursor>,
3762) -> Value {
3763 tracing::warn!(
3764 target: "mobkit::console",
3765 error = %err,
3766 "console timeline replay unavailable"
3767 );
3768 response_value(
3769 id,
3770 None,
3771 Some(JsonRpcError {
3772 code: crate::rpc::CONSOLE_TIMELINE_REPLAY_UNAVAILABLE_CODE,
3773 message: "timeline replay unavailable".to_string(),
3774 data: Some(json!({
3775 "error": "replay_unavailable",
3776 "stream": "timeline",
3777 "requested_cursor": requested_cursor.map(ToString::to_string),
3778 "latest_cursor": latest_cursor.map(|cursor| cursor.to_string()),
3779 })),
3780 }),
3781 )
3782}
3783
3784fn parse_console_helper_options(
3785 options_val: Option<&Value>,
3786) -> Result<meerkat_mob::HelperOptions, String> {
3787 crate::rpc::mob_methods::parse_helper_options(options_val)
3788}
3789
3790fn member_is_addressable(member: &meerkat_mob::runtime::MobMemberListEntry) -> bool {
3791 member
3792 .labels
3793 .get("addressable")
3794 .map(|value: &String| !value.eq_ignore_ascii_case("false"))
3795 .unwrap_or(true)
3796}
3797
3798fn member_addressability(member: &meerkat_mob::runtime::MobMemberListEntry) -> &'static str {
3799 if member_is_addressable(member) {
3800 "addressable"
3801 } else {
3802 "internal_only"
3803 }
3804}
3805
3806fn console_identity_status_json_for_identity(
3807 identity: &str,
3808 member: &meerkat_mob::runtime::MobMemberListEntry,
3809 session_id: Option<String>,
3810 response_phase: Option<String>,
3811) -> Value {
3812 json!({
3813 "identity": identity,
3814 "state": member_status_state_string(member.status),
3815 "role": member.role.to_string(),
3816 "addressability": member_addressability(member),
3817 "display_name": member.labels.get("display_name"),
3818 "labels": member.labels,
3819 "agent_runtime_id": crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()),
3820 "session_id": session_id,
3821 "generation": Value::Null,
3822 "checkpoint_version": Value::Null,
3823 "continuity_health": Value::Null,
3824 "lease_healthy": Value::Null,
3825 "lease": Value::Null,
3826 "response_phase": response_phase,
3827 })
3828}
3829
3830fn console_identity_inspect_json_for_identity(
3831 identity: &str,
3832 member: &meerkat_mob::runtime::MobMemberListEntry,
3833 session_id: Option<String>,
3834 response_phase: Option<String>,
3835) -> Value {
3836 let peers: Vec<String> = member.wired_to.iter().map(ToString::to_string).collect();
3837 json!({
3838 "identity": identity,
3839 "state": member_status_state_string(member.status),
3840 "role": member.role.to_string(),
3841 "addressability": member_addressability(member),
3842 "display_name": member.labels.get("display_name"),
3843 "labels": member.labels,
3844 "continuity_health": Value::Null,
3845 "lease_healthy": Value::Null,
3846 "lease": Value::Null,
3847 "continuity": {
3848 "generation": Value::Null,
3849 "checkpoint_version": Value::Null,
3850 "session_id": session_id,
3851 "agent_runtime_id": crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()),
3852 },
3853 "topology_peers": peers,
3854 "output_preview": Value::Null,
3855 "response_phase": response_phase,
3856 })
3857}
3858
3859async fn lookup_member_with_session(
3863 handle: &MobHandle,
3864 identity: &AgentIdentity,
3865) -> Option<(meerkat_mob::runtime::MobMemberListEntry, Option<String>)> {
3866 let entries = handle.list_members_including_retiring().await;
3867 let entry = entries
3868 .into_iter()
3869 .find(|e| &e.agent_identity == identity)?;
3870 let session_id = handle
3871 .resolve_bridge_session_id_observation(identity)
3872 .await
3873 .map(|s| s.to_string());
3874 Some((entry, session_id))
3875}
3876
3877#[derive(Debug, Clone)]
3878struct ConsoleRuntimeIdentityAlias {
3879 identity: String,
3880 runtime_member_id: String,
3881 member: meerkat_mob::runtime::MobMemberListEntry,
3882 session_id: Option<String>,
3883}
3884
3885pub(crate) struct SseMemberAuthorizationProjection {
3890 pub(crate) identity: String,
3891 pub(crate) attributes: AgentResourceAttributes,
3892 pub(crate) visible: bool,
3893}
3894
3895fn console_live_only_fallback_allowed(
3899 was_registered: bool,
3900 requested_identity: &str,
3901 live_alias: Option<&ConsoleRuntimeIdentityAlias>,
3902) -> bool {
3903 !was_registered
3904 && !crate::member_comms_id::is_reserved_generated_alias(requested_identity)
3905 && live_alias.is_some_and(|alias| {
3906 !crate::member_comms_id::is_reserved_generated_alias(&alias.runtime_member_id)
3907 })
3908}
3909
3910fn durable_identity_for_member(member: &meerkat_mob::runtime::MobMemberListEntry) -> String {
3911 crate::member_comms_id::durable_identity_label(&member.labels)
3912 .map(str::to_owned)
3913 .unwrap_or_else(|| {
3915 crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned()
3916 })
3917}
3918
3919async fn lookup_member_alias_with_session(
3920 handle: &MobHandle,
3921 visibility_policy: &dyn ConsoleVisibilityPolicy,
3922 requested_identity: &str,
3923) -> Result<Option<ConsoleRuntimeIdentityAlias>, JsonRpcError> {
3924 let all_matches = lookup_member_alias_candidates_with_session(handle, requested_identity).await;
3925 let mut visible_matches = Vec::new();
3926 for alias in &all_matches {
3927 if runtime_alias_visible_to_console(handle, visibility_policy, alias) {
3928 visible_matches.push(alias.clone());
3929 }
3930 }
3931 let member = if visible_matches.len() > 1 {
3932 return Err(ambiguous_live_identity_alias_error(
3933 requested_identity,
3934 &visible_matches
3935 .iter()
3936 .map(|alias| alias.runtime_member_id.clone())
3937 .collect::<Vec<_>>(),
3938 ));
3939 } else if let Some(alias) = visible_matches.into_iter().next() {
3940 Some(alias)
3941 } else {
3942 all_matches.into_iter().next()
3943 };
3944 Ok(member)
3945}
3946
3947async fn lookup_visible_member_alias_candidates_with_session(
3948 handle: &MobHandle,
3949 visibility_policy: &dyn ConsoleVisibilityPolicy,
3950 requested_identity: &str,
3951) -> Vec<ConsoleRuntimeIdentityAlias> {
3952 let mut visible = Vec::new();
3953 for alias in lookup_member_alias_candidates_with_session(handle, requested_identity).await {
3954 if runtime_alias_visible_to_console(handle, visibility_policy, &alias) {
3955 visible.push(alias);
3956 }
3957 }
3958 visible
3959}
3960
3961async fn lookup_member_alias_candidates_with_session(
3962 handle: &MobHandle,
3963 requested_identity: &str,
3964) -> Vec<ConsoleRuntimeIdentityAlias> {
3965 let requested_member_id = crate::member_comms_id::mob_member_id(requested_identity);
3968 let entries = handle.list_members_including_retiring().await;
3969 let exact_matches = entries
3970 .iter()
3971 .filter(|entry| entry.agent_identity == requested_member_id)
3972 .cloned()
3973 .collect::<Vec<_>>();
3974 let label_matches = entries
3975 .iter()
3976 .filter(|entry| {
3977 crate::member_comms_id::durable_identity_label(&entry.labels)
3978 .is_some_and(|identity| identity == requested_identity)
3979 })
3980 .cloned()
3981 .collect::<Vec<_>>();
3982 let mut matches = exact_matches;
3983 matches.extend(label_matches);
3984 let mut seen_member_ids = BTreeSet::new();
3985 matches.retain(|entry| seen_member_ids.insert(entry.agent_identity.to_string()));
3986 let mut aliases = Vec::with_capacity(matches.len());
3987 for member in matches {
3988 let runtime_member_id =
3989 crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned();
3990 let identity = durable_identity_for_member(&member);
3991 let session_id = handle
3992 .resolve_bridge_session_id_observation(&member.agent_identity)
3993 .await
3994 .map(|s| s.to_string());
3995 aliases.push(ConsoleRuntimeIdentityAlias {
3996 identity,
3997 runtime_member_id,
3998 member,
3999 session_id,
4000 });
4001 }
4002 aliases
4003}
4004
4005pub(crate) async fn sse_member_identity_visibility(
4013 handle: &MobHandle,
4014 visibility_policy: &dyn ConsoleVisibilityPolicy,
4015 requested_identity: &str,
4016) -> Option<(String, bool)> {
4017 let candidates = lookup_member_alias_candidates_with_session(handle, requested_identity).await;
4018 let identity = candidates.first()?.identity.clone();
4019 let visible = candidates
4020 .iter()
4021 .any(|alias| runtime_alias_visible_to_console(handle, visibility_policy, alias));
4022 Some((identity, visible))
4023}
4024
4025pub(crate) async fn sse_member_authorization_projection(
4030 handle: &MobHandle,
4031 runtime: Option<&MobRuntime>,
4032 visibility_policy: &dyn ConsoleVisibilityPolicy,
4033 requested_identity: &str,
4034 expected_runtime_id: &AgentRuntimeId,
4035 expected_fence_token: FenceToken,
4036) -> Option<SseMemberAuthorizationProjection> {
4037 let candidates = lookup_member_alias_candidates_with_session(handle, requested_identity).await;
4038 let alias = candidates.into_iter().find(|candidate| {
4039 candidate
4040 .member
4041 .binding_atoms()
4042 .is_some_and(|(runtime_id, fence_token)| {
4043 runtime_id == *expected_runtime_id && fence_token == expected_fence_token
4044 })
4045 })?;
4046
4047 let mut labels = alias.member.labels.clone();
4048 crate::console_spawn::sanitize_unverified_lineage_labels(&mut labels);
4051 if let Some(runtime) = runtime {
4052 let registered = runtime.console_identity_labels().await;
4053 if let Some(spawn_labels) = registered.get(&alias.identity) {
4054 crate::console_spawn::merge_registered_labels(&mut labels, spawn_labels);
4055 }
4056 }
4057 let visible = runtime_alias_visible_to_console(handle, visibility_policy, &alias);
4058 Some(SseMemberAuthorizationProjection {
4059 identity: alias.identity.clone(),
4060 attributes: AgentResourceAttributes {
4061 identity: alias.identity,
4062 agent_id: Some(alias.runtime_member_id),
4063 role: Some(alias.member.role.to_string()),
4064 labels,
4065 },
4066 visible,
4067 })
4068}
4069
4070async fn reject_ambiguous_projected_live_identity(
4071 handle: &MobHandle,
4072 visibility_policy: &dyn ConsoleVisibilityPolicy,
4073 alias: &ConsoleRuntimeIdentityAlias,
4074) -> Result<(), JsonRpcError> {
4075 let candidates = lookup_visible_member_alias_candidates_with_session(
4076 handle,
4077 visibility_policy,
4078 &alias.identity,
4079 )
4080 .await;
4081 if candidates.len() > 1 {
4082 return Err(ambiguous_live_identity_alias_error(
4083 &alias.identity,
4084 &candidates
4085 .iter()
4086 .map(|candidate| candidate.runtime_member_id.clone())
4087 .collect::<Vec<_>>(),
4088 ));
4089 }
4090 Ok(())
4091}
4092
4093fn ambiguous_live_identity_alias_error(
4094 requested_identity: &str,
4095 candidates: &[String],
4096) -> JsonRpcError {
4097 JsonRpcError {
4098 code: -32602,
4099 message: format!(
4100 "ambiguous live identity alias {requested_identity}: candidates [{}]",
4101 candidates.join(", ")
4102 ),
4103 data: Some(json!({
4104 "kind": "ambiguous_live_identity_alias",
4105 "identity": requested_identity,
4106 "candidates": candidates,
4107 })),
4108 }
4109}
4110
4111async fn lookup_member_runtime_alias_with_session(
4112 handle: &MobHandle,
4113 runtime_member_id: &str,
4114) -> Option<ConsoleRuntimeIdentityAlias> {
4115 let requested_member_id = crate::member_comms_id::mob_member_id(runtime_member_id);
4116 let entries = handle.list_members_including_retiring().await;
4117 let member = entries
4118 .into_iter()
4119 .find(|entry| entry.agent_identity == requested_member_id)?;
4120 let runtime_member_id =
4121 crate::member_comms_id::runtime_alias_str(member.agent_identity.as_str()).into_owned();
4122 let identity = durable_identity_for_member(&member);
4123 let session_id = handle
4124 .resolve_bridge_session_id_observation(&member.agent_identity)
4125 .await
4126 .map(|s| s.to_string());
4127 Some(ConsoleRuntimeIdentityAlias {
4128 identity,
4129 runtime_member_id,
4130 member,
4131 session_id,
4132 })
4133}
4134
4135async fn identity_runtime_alias(
4136 identity_runtime: &crate::identity_first::IdentityRuntime,
4137 requested_identity: &str,
4138) -> Result<Option<(crate::identity_first::AgentIdentity, bool)>, String> {
4139 let requested_identity = crate::member_comms_id::runtime_alias_str(requested_identity);
4140 let requested_identity = requested_identity.as_ref();
4141 if crate::member_comms_id::is_reserved_generated_alias(requested_identity) {
4142 for status in identity_runtime.statuses().await {
4143 if status
4144 .agent_runtime_id
4145 .as_ref()
4146 .is_some_and(|runtime_id| runtime_id.as_str() == requested_identity)
4147 {
4148 return Ok(Some((status.identity, false)));
4149 }
4150 }
4151 return Ok(None);
4152 }
4153 if let Ok(identity) = crate::identity_first::AgentIdentity::parse(requested_identity) {
4154 match identity_runtime.status(&identity).await {
4155 Ok(_) => return Ok(Some((identity, true))),
4156 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
4157 Err(err) => return Err(err.to_string()),
4158 }
4159 }
4160
4161 for status in identity_runtime.statuses().await {
4162 if status
4163 .agent_runtime_id
4164 .as_ref()
4165 .is_some_and(|runtime_id| runtime_id.as_str() == requested_identity)
4166 {
4167 return Ok(Some((status.identity, false)));
4168 }
4169 }
4170 Ok(None)
4171}
4172
4173async fn resolve_console_identity_control_target(
4174 handle: &MobHandle,
4175 identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
4176 visibility_policy: &dyn ConsoleVisibilityPolicy,
4177 requested_identity: &str,
4178) -> Result<
4179 Option<(
4180 crate::identity_first::AgentIdentity,
4181 bool,
4182 Option<ConsoleRuntimeIdentityAlias>,
4183 )>,
4184 JsonRpcError,
4185> {
4186 if let Some(identity_runtime) = identity_runtime {
4187 match identity_runtime_alias(identity_runtime, requested_identity).await {
4188 Ok(Some((identity, exact))) => {
4189 let live = if exact {
4190 let registered_live = match identity_runtime.status(&identity).await {
4191 Ok(status) => match status.agent_runtime_id.as_ref() {
4192 Some(runtime_id) => {
4193 lookup_member_runtime_alias_with_session(
4194 handle,
4195 runtime_id.as_str(),
4196 )
4197 .await
4198 }
4199 None => None,
4200 },
4201 Err(_) => None,
4202 };
4203 if let Some(alias) = registered_live.as_ref()
4204 && !runtime_alias_visible_to_console(handle, visibility_policy, alias)
4205 {
4206 return Err(identity_hidden_by_policy_error(requested_identity));
4207 }
4208 if let Some(registered) = registered_live {
4209 return Ok(Some((identity, true, Some(registered))));
4210 }
4211 let requested_live_candidates =
4212 lookup_visible_member_alias_candidates_with_session(
4213 handle,
4214 visibility_policy,
4215 requested_identity,
4216 )
4217 .await;
4218 let requested_live = if requested_live_candidates.len() > 1 {
4219 return Err(ambiguous_live_identity_alias_error(
4220 requested_identity,
4221 &requested_live_candidates
4222 .iter()
4223 .map(|alias| alias.runtime_member_id.clone())
4224 .collect::<Vec<_>>(),
4225 ));
4226 } else {
4227 requested_live_candidates.into_iter().next()
4228 };
4229 match (registered_live, requested_live) {
4230 (Some(registered), Some(requested))
4231 if registered.runtime_member_id == requested.runtime_member_id =>
4232 {
4233 Some(registered)
4234 }
4235 (Some(registered), None) => Some(registered),
4236 (Some(_registered), Some(requested)) => Some(requested),
4237 (None, requested) => requested,
4238 }
4239 } else {
4240 let registered_live =
4241 lookup_member_runtime_alias_with_session(handle, requested_identity).await;
4242 if let Some(alias) = registered_live.as_ref()
4243 && !runtime_alias_visible_to_console(handle, visibility_policy, alias)
4244 {
4245 return Err(identity_hidden_by_policy_error(requested_identity));
4246 }
4247 if let Some(registered) = registered_live {
4248 return Ok(Some((identity, true, Some(registered))));
4249 }
4250 let durable_live_candidates =
4251 lookup_visible_member_alias_candidates_with_session(
4252 handle,
4253 visibility_policy,
4254 identity.as_str(),
4255 )
4256 .await;
4257 let durable_live = if durable_live_candidates.len() > 1 {
4258 return Err(ambiguous_live_identity_alias_error(
4259 identity.as_str(),
4260 &durable_live_candidates
4261 .iter()
4262 .map(|alias| alias.runtime_member_id.clone())
4263 .collect::<Vec<_>>(),
4264 ));
4265 } else {
4266 durable_live_candidates.into_iter().next()
4267 };
4268 match (registered_live, durable_live) {
4269 (Some(registered), Some(durable))
4270 if registered.runtime_member_id == durable.runtime_member_id =>
4271 {
4272 Some(registered)
4273 }
4274 (Some(registered), None) => Some(registered),
4275 (Some(_registered), Some(durable)) => Some(durable),
4276 (None, durable) => durable,
4277 }
4278 };
4279 return Ok(Some((identity, true, live)));
4284 }
4285 Ok(None) => {}
4286 Err(err) => {
4287 return Err(JsonRpcError {
4288 code: -32000,
4289 message: err,
4290 data: None,
4291 });
4292 }
4293 }
4294 }
4295
4296 let live_alias =
4297 lookup_member_alias_with_session(handle, visibility_policy, requested_identity).await?;
4298 let Some(alias) = live_alias else {
4299 return Ok(None);
4300 };
4301 if !runtime_alias_visible_to_console(handle, visibility_policy, &alias) {
4302 return Err(identity_hidden_by_policy_error(requested_identity));
4303 }
4304 if let Some(identity_runtime) = identity_runtime
4305 && let Some(bound_status) = identity_runtime
4306 .statuses()
4307 .await
4308 .into_iter()
4309 .find(|status| {
4310 status
4311 .agent_runtime_id
4312 .as_ref()
4313 .is_some_and(|runtime_id| runtime_id.as_str() == alias.runtime_member_id)
4314 })
4315 && bound_status.identity.as_str() != alias.identity
4316 {
4317 return Err(JsonRpcError {
4318 code: -32000,
4319 message: format!(
4320 "stale live identity alias: live console alias {} resolves to {}, but identity runtime binding belongs to {}",
4321 alias.identity,
4322 alias.runtime_member_id,
4323 bound_status.identity.as_str(),
4324 ),
4325 data: Some(json!({
4326 "kind": "stale_live_identity_alias",
4327 "identity": alias.identity,
4328 "runtime_member_id": alias.runtime_member_id,
4329 "registered_identity": bound_status.identity.as_str(),
4330 })),
4331 });
4332 }
4333 let identity = crate::identity_first::AgentIdentity::parse(&alias.identity).map_err(|err| {
4334 JsonRpcError {
4335 code: -32602,
4336 message: format!("invalid identity: {err}"),
4337 data: None,
4338 }
4339 })?;
4340 let durable_live_candidates = lookup_visible_member_alias_candidates_with_session(
4341 handle,
4342 visibility_policy,
4343 identity.as_str(),
4344 )
4345 .await;
4346 if durable_live_candidates.len() > 1 {
4347 return Err(ambiguous_live_identity_alias_error(
4348 identity.as_str(),
4349 &durable_live_candidates
4350 .iter()
4351 .map(|alias| alias.runtime_member_id.clone())
4352 .collect::<Vec<_>>(),
4353 ));
4354 }
4355 Ok(Some((identity, false, Some(alias))))
4356}
4357
4358fn live_alias_matches_status_runtime(
4359 alias: Option<&ConsoleRuntimeIdentityAlias>,
4360 status: &crate::identity_first::IdentityStatus,
4361) -> bool {
4362 let Some(alias) = alias else {
4363 return true;
4364 };
4365 let session_matches = match (
4366 status.session_id.as_ref().map(ToString::to_string),
4367 alias.session_id.as_deref(),
4368 ) {
4369 (Some(status_session), Some(live_session)) => status_session == live_session,
4370 _ => true,
4371 };
4372 status
4373 .agent_runtime_id
4374 .as_ref()
4375 .is_some_and(|runtime_id| runtime_id.as_str() == alias.runtime_member_id)
4376 && alias.identity == status.identity.as_str()
4377 && session_matches
4378}
4379
4380async fn stale_live_alias_json_rpc_error(
4381 operation: &str,
4382 identity_runtime: &crate::identity_first::IdentityRuntime,
4383 identity: &crate::identity_first::AgentIdentity,
4384 live_alias: Option<&ConsoleRuntimeIdentityAlias>,
4385) -> Option<JsonRpcError> {
4386 let live_alias = live_alias?;
4387 let Ok(status) = identity_runtime.status(identity).await else {
4388 return None;
4389 };
4390 if live_alias_matches_status_runtime(Some(live_alias), &status) {
4391 return None;
4392 }
4393 let registered_runtime_member_id = status
4394 .agent_runtime_id
4395 .as_ref()
4396 .map(crate::identity_first::AgentRuntimeId::as_str);
4397 Some(JsonRpcError {
4398 code: -32000,
4399 message: format!(
4400 "{operation} failed: identity runtime binding for {} points at {}, but requested live member is {}",
4401 identity.as_str(),
4402 registered_runtime_member_id.unwrap_or("<none>"),
4403 live_alias.runtime_member_id
4404 ),
4405 data: Some(json!({
4406 "kind": "stale_identity_runtime_binding",
4407 "identity": identity.as_str(),
4408 "registered_runtime_member_id": registered_runtime_member_id,
4409 "live_runtime_member_id": live_alias.runtime_member_id,
4410 "registered_session_id": status.session_id.as_ref().map(ToString::to_string),
4411 "live_session_id": live_alias.session_id,
4412 })),
4413 })
4414}
4415
4416fn identity_from_runtime_alias(alias: &str) -> Option<crate::identity_first::AgentIdentity> {
4417 let alias = crate::member_comms_id::runtime_alias_str(alias);
4418 let rest = alias.strip_prefix("rt:")?;
4419 let (identity, generation) = rest.rsplit_once(':')?;
4420 if identity.is_empty()
4421 || generation.is_empty()
4422 || !generation.chars().all(|ch| ch.is_ascii_digit())
4423 {
4424 return None;
4425 }
4426 crate::identity_first::AgentIdentity::parse(identity).ok()
4427}
4428
4429async fn stale_runtime_alias_json_rpc_error(
4430 operation: &str,
4431 identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
4432 alias: &str,
4433) -> Option<JsonRpcError> {
4434 let identity_runtime = identity_runtime?;
4435 let alias = crate::member_comms_id::runtime_alias_str(alias).into_owned();
4436 let identity = identity_from_runtime_alias(&alias)?;
4437 let status = identity_runtime.status(&identity).await.ok();
4438 let registered_runtime_member_id = status
4439 .as_ref()
4440 .and_then(|status| status.agent_runtime_id.as_ref())
4441 .map(crate::identity_first::AgentRuntimeId::as_str);
4442 if registered_runtime_member_id == Some(alias.as_str()) {
4443 return None;
4444 }
4445 Some(JsonRpcError {
4446 code: -32000,
4447 message: format!(
4448 "{operation} failed: identity runtime binding for {} points at {}, but requested live member is {}",
4449 identity.as_str(),
4450 registered_runtime_member_id.unwrap_or("<none>"),
4451 alias
4452 ),
4453 data: Some(json!({
4454 "kind": "stale_identity_runtime_binding",
4455 "identity": identity.as_str(),
4456 "registered_runtime_member_id": registered_runtime_member_id,
4457 "live_runtime_member_id": alias,
4458 "registered_session_id": status.as_ref().and_then(|status| status.session_id.as_ref()).map(ToString::to_string),
4459 "live_session_id": Value::Null,
4460 })),
4461 })
4462}
4463
4464fn reset_requires_session_bridge_json_rpc_error() -> JsonRpcError {
4465 JsonRpcError {
4466 code: -32602,
4467 message: "reset requires an identity runtime with a session bridge".to_string(),
4468 data: Some(json!({
4469 "kind": "identity_reset_requires_session_bridge",
4470 })),
4471 }
4472}
4473
4474fn console_identity_status_json_from_record(
4475 record: &crate::console_aggregator::ConsoleIdentityRecord,
4476 response_phase: Option<String>,
4477) -> Value {
4478 json!({
4479 "identity": record.identity,
4480 "state": record.health,
4481 "role": record.labels.get("role"),
4482 "addressability": if record.addressable { "addressable" } else { "internal_only" },
4483 "display_name": record.display_name,
4484 "labels": record.labels,
4485 "agent_runtime_id": record.runtime_member_id,
4486 "session_id": record.session_id,
4487 "generation": Value::Null,
4488 "checkpoint_version": Value::Null,
4489 "continuity_health": Value::Null,
4490 "lease_healthy": Value::Null,
4491 "lease": Value::Null,
4492 "response_phase": response_phase,
4493 })
4494}
4495
4496fn console_addressability_json(
4497 addressability: crate::identity_first::AgentAddressability,
4498) -> &'static str {
4499 match addressability {
4500 crate::identity_first::AgentAddressability::Addressable => "addressable",
4501 crate::identity_first::AgentAddressability::InternalOnly => "internal_only",
4502 }
4503}
4504
4505fn console_identity_record_from_identity_status(
4506 status: &crate::identity_first::IdentityStatus,
4507) -> ConsoleIdentityRecord {
4508 let mut labels = status.labels.clone();
4509 if let Some(profile) = status.profile.as_ref() {
4510 labels
4511 .entry("role".to_string())
4512 .or_insert_with(|| profile.as_str().to_string());
4513 }
4514 let runtime_member_id = status
4515 .agent_runtime_id
4516 .as_ref()
4517 .map(crate::identity_first::AgentRuntimeId::as_str)
4518 .unwrap_or_else(|| status.identity.as_str())
4519 .to_string();
4520 let addressable = status.addressability
4521 == crate::identity_first::AgentAddressability::Addressable
4522 && matches!(
4523 status.state,
4524 crate::identity_first::IdentityLifecycleState::Active
4525 | crate::identity_first::IdentityLifecycleState::Dormant
4526 | crate::identity_first::IdentityLifecycleState::Uninitialized
4527 );
4528 let visibility = match status.state {
4529 crate::identity_first::IdentityLifecycleState::Retiring => {
4530 ConsoleVisibility::RetiredReadable
4531 }
4532 crate::identity_first::IdentityLifecycleState::Broken
4533 | crate::identity_first::IdentityLifecycleState::Suspended => {
4534 ConsoleVisibility::Unreachable
4535 }
4536 _ if addressable => ConsoleVisibility::Addressable,
4537 _ => ConsoleVisibility::Hidden,
4538 };
4539 let health = match status.state {
4540 crate::identity_first::IdentityLifecycleState::Active => "ready",
4541 crate::identity_first::IdentityLifecycleState::Dormant => "dormant",
4542 crate::identity_first::IdentityLifecycleState::Uninitialized => "uninitialized",
4543 crate::identity_first::IdentityLifecycleState::Broken => "broken",
4544 crate::identity_first::IdentityLifecycleState::Suspended => "suspended",
4545 crate::identity_first::IdentityLifecycleState::Retiring => "retired",
4546 }
4547 .to_string();
4548 ConsoleIdentityRecord {
4549 identity: status.identity.as_str().to_string(),
4550 display_name: status
4551 .display_name
4552 .as_ref()
4553 .map(crate::identity_first::DisplayName::as_str)
4554 .unwrap_or_else(|| status.identity.as_str())
4555 .to_string(),
4556 runtime_key: "identity-first".to_string(),
4557 runtime_member_id,
4558 session_id: status.session_id.as_ref().map(ToString::to_string),
4559 visibility,
4560 addressable,
4561 health,
4562 topology_peers: Vec::new(),
4563 labels,
4564 }
4565}
4566
4567fn identity_hidden_by_policy_response(response_id: Value, identity: &str) -> Value {
4568 response_value(
4569 response_id,
4570 None,
4571 Some(identity_hidden_by_policy_error(identity)),
4572 )
4573}
4574
4575fn identity_hidden_by_policy_error(identity: &str) -> JsonRpcError {
4576 JsonRpcError {
4577 code: -32001,
4578 message: format!("unknown identity: {identity}"),
4579 data: Some(json!({
4580 "kind": "identity_hidden_by_policy",
4581 "identity": identity,
4582 })),
4583 }
4584}
4585
4586fn identity_status_visible_to_console(
4587 visibility_policy: &dyn ConsoleVisibilityPolicy,
4588 status: &crate::identity_first::IdentityStatus,
4589) -> bool {
4590 visibility_policy.identity_visible(&console_identity_record_from_identity_status(status))
4591}
4592
4593fn console_member_from_runtime_alias(
4594 handle: &MobHandle,
4595 alias: &ConsoleRuntimeIdentityAlias,
4596) -> ConsoleMember {
4597 ConsoleMember {
4598 agent_identity: alias.runtime_member_id.clone(),
4599 role: alias.member.role.to_string(),
4600 state: member_status_state_string(alias.member.status),
4601 model_capabilities: model_capabilities_for_member_entry(handle.definition(), &alias.member),
4602 runtime_mode: Some(alias.member.runtime_mode.to_string()),
4603 session_id: alias.session_id.clone(),
4604 wired_to: alias
4605 .member
4606 .wired_to
4607 .iter()
4608 .map(|peer| crate::member_comms_id::runtime_alias_str(peer.as_str()).into_owned())
4609 .collect(),
4610 labels: alias.member.labels.clone(),
4611 progress: None,
4612 }
4613}
4614
4615fn console_identity_record_from_runtime_alias(
4616 alias: &ConsoleRuntimeIdentityAlias,
4617) -> ConsoleIdentityRecord {
4618 let addressable = alias
4619 .member
4620 .labels
4621 .get("addressable")
4622 .map(|value| !value.eq_ignore_ascii_case("false"))
4623 .unwrap_or(true)
4624 && alias.member.status == meerkat_mob::MobMemberStatus::Active;
4625 let visibility = match alias.member.status {
4626 meerkat_mob::MobMemberStatus::Retiring | meerkat_mob::MobMemberStatus::Completed => {
4627 ConsoleVisibility::RetiredReadable
4628 }
4629 meerkat_mob::MobMemberStatus::Broken | meerkat_mob::MobMemberStatus::Unknown => {
4632 ConsoleVisibility::Unreachable
4633 }
4634 _ if addressable => ConsoleVisibility::Addressable,
4635 _ => ConsoleVisibility::Hidden,
4636 };
4637 ConsoleIdentityRecord {
4638 identity: alias.identity.clone(),
4639 display_name: alias
4640 .member
4641 .labels
4642 .get("display_name")
4643 .cloned()
4644 .unwrap_or_else(|| alias.identity.clone()),
4645 runtime_key: "runtime".to_string(),
4646 runtime_member_id: alias.runtime_member_id.clone(),
4647 session_id: alias.session_id.clone(),
4648 visibility,
4649 addressable,
4650 health: match alias.member.status {
4651 meerkat_mob::MobMemberStatus::Active => "ready".to_string(),
4652 meerkat_mob::MobMemberStatus::Retiring => "retired".to_string(),
4653 other => format!("{other:?}").to_ascii_lowercase(),
4656 },
4657 topology_peers: alias
4658 .member
4659 .wired_to
4660 .iter()
4661 .map(ToString::to_string)
4662 .collect(),
4663 labels: alias.member.labels.clone(),
4664 }
4665}
4666
4667fn runtime_alias_visible_to_console(
4668 handle: &MobHandle,
4669 visibility_policy: &dyn ConsoleVisibilityPolicy,
4670 alias: &ConsoleRuntimeIdentityAlias,
4671) -> bool {
4672 let member = console_member_from_runtime_alias(handle, alias);
4673 if !visibility_policy.member_visible(&member) {
4674 return false;
4675 }
4676 visibility_policy.identity_visible(&console_identity_record_from_runtime_alias(alias))
4677}
4678
4679fn console_identity_status_json_from_identity_status(
4680 status: &crate::identity_first::IdentityStatus,
4681 response_phase: Option<String>,
4682) -> Value {
4683 json!({
4684 "identity": status.identity.as_str(),
4685 "state": status.state.wire_str(),
4686 "role": status.profile.as_ref().map(ProfileName::as_str),
4687 "addressability": console_addressability_json(status.addressability),
4688 "display_name": status.display_name.as_ref().map(crate::identity_first::DisplayName::as_str),
4689 "labels": status.labels,
4690 "agent_runtime_id": status.agent_runtime_id.as_ref().map(crate::identity_first::AgentRuntimeId::as_str),
4691 "session_id": status.session_id.as_ref().map(ToString::to_string),
4692 "generation": status.generation.map(crate::identity_first::ContinuityGeneration::get),
4693 "checkpoint_version": status.checkpoint_version.map(crate::identity_first::CheckpointVersion::get),
4694 "continuity_health": status.continuity_health,
4695 "lease_healthy": status.lease.as_ref().map(|lease| lease.healthy),
4696 "lease": status.lease.as_ref().map(|lease| json!({
4697 "fencing_token": lease.fencing_token.get(),
4698 "ttl_remaining_ms": lease.ttl_remaining.as_millis() as u64,
4699 "healthy": lease.healthy,
4700 })),
4701 "response_phase": response_phase,
4702 })
4703}
4704
4705fn console_identity_inspect_json_from_identity_status(
4706 status: &crate::identity_first::IdentityStatus,
4707 live_alias: Option<&ConsoleRuntimeIdentityAlias>,
4708 response_phase: Option<String>,
4709) -> Value {
4710 let topology_peers = live_alias
4711 .map(|alias| {
4712 alias
4713 .member
4714 .wired_to
4715 .iter()
4716 .map(ToString::to_string)
4717 .map(Value::String)
4718 .collect::<Vec<_>>()
4719 })
4720 .unwrap_or_default();
4721 let session_id = status
4722 .session_id
4723 .as_ref()
4724 .map(ToString::to_string)
4725 .or_else(|| live_alias.and_then(|alias| alias.session_id.clone()));
4726 let agent_runtime_id = status
4727 .agent_runtime_id
4728 .as_ref()
4729 .map(crate::identity_first::AgentRuntimeId::as_str)
4730 .map(ToString::to_string)
4731 .or_else(|| live_alias.map(|alias| alias.runtime_member_id.clone()));
4732 json!({
4733 "identity": status.identity.as_str(),
4734 "state": status.state.wire_str(),
4735 "role": status.profile.as_ref().map(ProfileName::as_str),
4736 "addressability": console_addressability_json(status.addressability),
4737 "display_name": status.display_name.as_ref().map(crate::identity_first::DisplayName::as_str),
4738 "labels": status.labels,
4739 "continuity_health": status.continuity_health,
4740 "lease_healthy": status.lease.as_ref().map(|lease| lease.healthy),
4741 "lease": status.lease.as_ref().map(|lease| json!({
4742 "fencing_token": lease.fencing_token.get(),
4743 "ttl_remaining_ms": lease.ttl_remaining.as_millis() as u64,
4744 "healthy": lease.healthy,
4745 })),
4746 "continuity": {
4747 "generation": status.generation.map(crate::identity_first::ContinuityGeneration::get),
4748 "checkpoint_version": status.checkpoint_version.map(crate::identity_first::CheckpointVersion::get),
4749 "session_id": session_id,
4750 "agent_runtime_id": agent_runtime_id,
4751 },
4752 "topology_peers": topology_peers,
4753 "output_preview": Value::Null,
4754 "response_phase": response_phase,
4755 })
4756}
4757
4758fn console_identity_inspect_json_from_record(
4759 inspection: &crate::console_aggregator::ConsoleIdentityInspection,
4760 response_phase: Option<String>,
4761) -> Value {
4762 let record = &inspection.identity;
4763 json!({
4764 "identity": record.identity,
4765 "state": record.health,
4766 "role": record.labels.get("role"),
4767 "addressability": if record.addressable { "addressable" } else { "internal_only" },
4768 "display_name": record.display_name,
4769 "labels": record.labels,
4770 "continuity_health": Value::Null,
4771 "lease_healthy": Value::Null,
4772 "lease": Value::Null,
4773 "continuity": {
4774 "generation": Value::Null,
4775 "checkpoint_version": Value::Null,
4776 "session_id": record.session_id,
4777 "agent_runtime_id": record.runtime_member_id,
4778 },
4779 "topology_peers": inspection.peers,
4780 "output_preview": Value::Null,
4781 "response_phase": response_phase,
4782 })
4783}
4784
4785fn lifecycle_archive_cleanup_completed(error: &str) -> bool {
4786 is_recoverable_lifecycle_cleanup_error(error)
4787}
4788
4789async fn respawn_console_member(
4790 handle: &MobHandle,
4791 runtime_member_id: &AgentIdentity,
4792) -> Result<Option<Value>, String> {
4793 let entry_before_respawn = handle.get_member(runtime_member_id).await.ok().flatten();
4796 match handle.respawn(runtime_member_id.clone(), None).await {
4797 Ok(_receipt) => Ok(None),
4798 Err(err) => {
4799 if let Some(failed_peer_ids) = topology_restore_failed_peer_ids(&err) {
4800 tracing::warn!(
4801 member_id = %runtime_member_id,
4802 failed_peer_count = failed_peer_ids.len(),
4803 failed_peer_ids = ?failed_peer_ids,
4804 "console member respawn restored member with isolated peer edges; continuing degraded respawn"
4805 );
4806 return Ok(Some(topology_restore_warning_json(&failed_peer_ids)));
4807 }
4808
4809 if lifecycle_archive_cleanup_completed(&err.to_string()) {
4810 if handle
4813 .get_member(runtime_member_id)
4814 .await
4815 .map_err(|lookup_err| lookup_err.to_string())?
4816 .is_none()
4817 && let Some(entry) = entry_before_respawn
4818 {
4819 let mut spec =
4820 SpawnMemberSpec::new(entry.role.clone(), runtime_member_id.clone());
4821 if !entry.labels.is_empty() {
4822 spec = spec.with_labels(entry.labels.clone());
4823 }
4824 handle
4825 .ensure_member(spec)
4826 .await
4827 .map_err(|ensure_err| ensure_err.to_string())?;
4828 }
4829 return Ok(None);
4830 }
4831
4832 Err(err.to_string())
4833 }
4834 }
4835}
4836
4837async fn respawn_live_console_member_response(
4841 handle: &MobHandle,
4842 console_events: Option<&ConsoleEventStore>,
4843 alias: &ConsoleRuntimeIdentityAlias,
4844 lifecycle_kind: &str,
4845 action: &str,
4846 response_id: Value,
4847) -> Value {
4848 let mid = crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str());
4849 match Box::pin(respawn_console_member(handle, &mid)).await {
4850 Ok(topology_restore_warning) => {
4851 if let Some(store) = console_events {
4852 store
4853 .record_lifecycle(
4854 &alias.identity,
4855 lifecycle_kind,
4856 json!({ "topology_restore_warning": topology_restore_warning.clone() }),
4857 )
4858 .await;
4859 }
4860 let mut body = match lookup_member_with_session(handle, &mid).await {
4861 Some((entry, session_id)) => console_identity_status_json_for_identity(
4862 &alias.identity,
4863 &entry,
4864 session_id,
4865 None,
4866 ),
4867 None => json!({ "identity": alias.identity }),
4868 };
4869 if let Some(warning) = topology_restore_warning {
4870 body["topology_restore_warning"] = warning;
4871 }
4872 response_value(response_id, Some(body), None)
4873 }
4874 Err(err) => internal_error(response_id, format!("{action} failed: {err}")),
4875 }
4876}
4877
4878async fn retire_console_member(
4879 handle: &MobHandle,
4880 runtime_member_id: &AgentIdentity,
4881) -> Result<(), String> {
4882 match handle.retire(runtime_member_id.clone()).await {
4883 Ok(()) => Ok(()),
4884 Err(err) if lifecycle_archive_cleanup_completed(&err.to_string()) => Ok(()),
4885 Err(err) => Err(err.to_string()),
4886 }
4887}
4888
4889#[cfg(test)]
4890fn member_id_matches_durable_identity(member_id: &str, durable_identity: &str) -> bool {
4891 member_id == durable_identity
4892}
4893
4894fn console_runtime_alias_generation(alias: &str, durable_identity: &str) -> Option<u64> {
4895 let alias = crate::member_comms_id::runtime_alias_str(alias);
4896 let rest = alias.strip_prefix("rt:")?;
4897 let (identity, generation) = rest.rsplit_once(':')?;
4898 if identity != durable_identity {
4899 return None;
4900 }
4901 generation.parse().ok()
4902}
4903
4904async fn stale_console_member_ids_for_identity(
4905 handle: &MobHandle,
4906 visibility_policy: &dyn ConsoleVisibilityPolicy,
4907 durable_identity: &str,
4908 current_runtime_member_id: Option<&str>,
4909 include_current: bool,
4910) -> Vec<AgentIdentity> {
4911 let Some(current_generation) = current_runtime_member_id
4912 .and_then(|alias| console_runtime_alias_generation(alias, durable_identity))
4913 else {
4914 return Vec::new();
4915 };
4916 lookup_member_alias_candidates_with_session(handle, durable_identity)
4917 .await
4918 .into_iter()
4919 .filter(|alias| {
4920 runtime_alias_visible_to_console(handle, visibility_policy, alias)
4921 && console_runtime_alias_generation(
4922 alias.runtime_member_id.as_str(),
4923 durable_identity,
4924 )
4925 .is_some_and(|generation| {
4926 generation < current_generation
4927 || (include_current && generation == current_generation)
4928 })
4929 })
4930 .map(|alias| crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str()))
4931 .collect()
4932}
4933
4934async fn stale_console_member_ids_for_identity_authoritative(
4940 handle: &MobHandle,
4941 durable_identity: &str,
4942 current_runtime_member_id: &str,
4943 include_current: bool,
4944) -> Vec<AgentIdentity> {
4945 let Some(current_generation) =
4946 console_runtime_alias_generation(current_runtime_member_id, durable_identity)
4947 else {
4948 return Vec::new();
4949 };
4950 lookup_member_alias_candidates_with_session(handle, durable_identity)
4951 .await
4952 .into_iter()
4953 .filter(|alias| {
4954 console_runtime_alias_generation(alias.runtime_member_id.as_str(), durable_identity)
4955 .is_some_and(|generation| {
4956 generation < current_generation
4957 || (include_current && generation == current_generation)
4958 })
4959 })
4960 .map(|alias| crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str()))
4961 .collect()
4962}
4963
4964async fn retire_console_member_ids(
4965 handle: &MobHandle,
4966 member_ids: Vec<AgentIdentity>,
4967) -> Result<(), String> {
4968 for member_id in member_ids {
4969 retire_console_member(handle, &member_id).await?;
4970 }
4971 Ok(())
4972}
4973
4974fn console_identity_error_response(
4975 response_id: Value,
4976 operation: &str,
4977 err: crate::identity_first::IdentityRuntimeError,
4978) -> Value {
4979 match err {
4980 crate::identity_first::IdentityRuntimeError::UnknownIdentity(identity) => {
4981 invalid_params(response_id, format!("identity not found: {identity}"))
4982 }
4983 other => internal_error(response_id, format!("{operation} failed: {other}")),
4984 }
4985}
4986
4987#[allow(clippy::too_many_arguments)]
4988async fn handle_console_aggregator_rpc(
4989 console_aggregator: Option<MobKitConsoleAggregator>,
4990 request: JsonRpcRequest,
4991 is_authenticated: bool,
4992 read_only: bool,
4993 access: Option<&AccessController>,
4994 access_view: Option<&AccessView>,
4995) -> Value {
4996 let response_id = request.id.clone().unwrap_or(Value::Null);
4997 if let Err(message) =
4998 crate::member_comms_id::validate_public_rpc_member_aliases(&request.params)
4999 {
5000 return invalid_params(response_id, message);
5001 }
5002 let can_mutate = is_authenticated && !read_only;
5003 if is_console_mutating_rpc_method(request.method.as_str()) && !can_mutate {
5004 return console_read_only_rpc_error(response_id);
5005 }
5006 if let Some(response) = handle_access_admin_rpc(access, access_view, &request) {
5007 return response;
5008 }
5009 if let Some(error) =
5010 console_rpc_access_violation(access_view, request.method.as_str(), &request.params)
5011 {
5012 return response_value(response_id, None, Some(error));
5013 }
5014 match request.method.as_str() {
5015 "mobkit/capabilities" => {
5016 let mut methods = vec![
5017 "mobkit/capabilities",
5018 "mobkit/console/list_identities",
5019 "mobkit/console/inspect_identity",
5020 "mobkit/console/query_timeline",
5021 ];
5022 if can_mutate {
5023 methods.extend_from_slice(&["mobkit/retire", "mobkit/console/send"]);
5024 }
5025 if access.is_some() {
5026 methods.push("mobkit/access/status");
5027 if access_view.is_some_and(AccessView::can_administer) {
5028 methods.extend_from_slice(&["mobkit/access/get", "mobkit/access/preview"]);
5029 if can_mutate {
5030 methods.extend_from_slice(&[
5031 "mobkit/access/set",
5032 "mobkit/access/enable",
5033 "mobkit/access/rules/upsert",
5034 "mobkit/access/rules/delete",
5035 "mobkit/access/groups/set",
5036 "mobkit/access/groups/delete",
5037 ]);
5038 }
5039 }
5040 }
5041 response_value(
5042 response_id,
5043 Some(json!({
5044 "methods": methods,
5045 "authenticated": is_authenticated,
5046 "read_only": read_only,
5047 "runtime_capabilities": {
5048 "can_send_messages": can_mutate,
5049 "can_retire_members": can_mutate,
5050 "can_spawn_members": false,
5051 },
5052 "features": {
5053 "console_aggregator": console_aggregator.is_some(),
5054 "multi_runtime_console": console_aggregator.is_some(),
5055 }
5056 })),
5057 None,
5058 )
5059 }
5060 "mobkit/console/list_identities" => {
5061 let Some(aggregator) = &console_aggregator else {
5062 return console_aggregator_unavailable(response_id);
5063 };
5064 match Box::pin(aggregator.list_identities()).await {
5065 Ok(mut identities) => {
5066 retain_visible_identity_records(&mut identities, access_view);
5067 response_value(response_id, Some(json!({ "identities": identities })), None)
5068 }
5069 Err(err) => internal_error(response_id, format!("list_identities failed: {err}")),
5070 }
5071 }
5072 "mobkit/console/inspect_identity" => {
5073 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5074 return invalid_params(response_id, "identity required");
5075 };
5076 let Some(aggregator) = &console_aggregator else {
5077 return console_aggregator_unavailable(response_id);
5078 };
5079 match Box::pin(aggregator.inspect_identity(identity)).await {
5080 Ok(Some(inspection)) => response_value(
5081 response_id,
5082 Some(serde_json::to_value(inspection).unwrap_or(Value::Null)),
5083 None,
5084 ),
5085 Ok(None) => response_value(
5086 response_id,
5087 None,
5088 Some(JsonRpcError {
5089 code: -32001,
5090 message: format!("unknown identity: {identity}"),
5091 data: None,
5092 }),
5093 ),
5094 Err(err) => internal_error(response_id, format!("inspect_identity failed: {err}")),
5095 }
5096 }
5097 "mobkit/console/query_timeline" => {
5098 let query: ConsoleTimelineWindowQuery =
5099 match serde_json::from_value(request.params.clone()) {
5100 Ok(query) => query,
5101 Err(err) => {
5102 return invalid_params(response_id, format!("invalid query params: {err}"));
5103 }
5104 };
5105 let Some(aggregator) = &console_aggregator else {
5106 return console_aggregator_unavailable(response_id);
5107 };
5108 match Box::pin(aggregator.query_timeline_windowed(query.clone())).await {
5109 Ok(mut page) => {
5110 retain_visible_timeline_frames(&mut page, access_view);
5111 response_value(
5112 response_id,
5113 Some(serde_json::to_value(page).unwrap_or(Value::Null)),
5114 None,
5115 )
5116 }
5117 Err(err) => {
5118 let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
5119 console_timeline_replay_unavailable_response(
5120 response_id,
5121 err,
5122 query.after.as_ref(),
5123 latest_cursor,
5124 )
5125 }
5126 }
5127 }
5128 "mobkit/console/send" => {
5129 let send_request: ConsoleSendRequest =
5130 match serde_json::from_value(request.params.clone()) {
5131 Ok(request) => request,
5132 Err(err) => {
5133 return invalid_params(response_id, format!("invalid send params: {err}"));
5134 }
5135 };
5136 let Some(aggregator) = &console_aggregator else {
5137 return console_aggregator_unavailable(response_id);
5138 };
5139 match Box::pin(aggregator.send(send_request)).await {
5140 Ok(accepted) => response_value(
5141 response_id,
5142 Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
5143 None,
5144 ),
5145 Err(err) => {
5146 response_value(response_id, None, Some(console_send_json_rpc_error(err)))
5147 }
5148 }
5149 }
5150 "mobkit/retire" => {
5151 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5152 return invalid_params(response_id, "identity required");
5153 };
5154 let Some(aggregator) = &console_aggregator else {
5155 return console_aggregator_unavailable(response_id);
5156 };
5157 match Box::pin(aggregator.retire_identity(identity)).await {
5158 Ok(true) => {
5159 response_value(response_id, Some(json!({ "identity": identity })), None)
5160 }
5161 Ok(false) => response_value(
5162 response_id,
5163 None,
5164 Some(JsonRpcError {
5165 code: -32001,
5166 message: format!("unknown identity: {identity}"),
5167 data: None,
5168 }),
5169 ),
5170 Err(err) => internal_error(response_id, format!("retire failed: {err}")),
5171 }
5172 }
5173 "mobkit/reset_all" => {
5174 let Some(_aggregator) = &console_aggregator else {
5175 return console_aggregator_unavailable(response_id);
5176 };
5177 response_value(
5178 response_id,
5179 None,
5180 Some(JsonRpcError {
5181 code: -32002,
5182 message: "reset_all is not supported on the aggregator-only RPC surface"
5183 .to_string(),
5184 data: Some(json!({
5185 "kind": "unsupported_reset_all_surface",
5186 "reason": "aggregator reset_all cannot preserve baseline identity semantics",
5187 })),
5188 }),
5189 )
5190 }
5191 _ => response_value(
5192 response_id,
5193 None,
5194 Some(JsonRpcError {
5195 code: -32601,
5196 message: "Method not found".to_string(),
5197 data: None,
5198 }),
5199 ),
5200 }
5201}
5202
5203fn console_aggregator_unavailable(response_id: Value) -> Value {
5204 response_value(
5205 response_id,
5206 None,
5207 Some(JsonRpcError {
5208 code: -32004,
5209 message: "console aggregator unavailable".to_string(),
5210 data: None,
5211 }),
5212 )
5213}
5214
5215fn resolve_gating_approver_id(
5216 params: &Value,
5217 authenticated_principal: Option<&str>,
5218) -> Result<String, &'static str> {
5219 if let Some(principal) = authenticated_principal.and_then(|value| {
5220 let trimmed = value.trim();
5221 (!trimmed.is_empty()).then_some(trimmed)
5222 }) {
5223 return Ok(principal.to_string());
5224 }
5225 let Some(approver_id) = params.get("approver_id").and_then(Value::as_str) else {
5226 return Err("approver_id required");
5227 };
5228 let trimmed = approver_id.trim();
5229 if trimmed.is_empty() {
5230 return Err("approver_id required");
5231 }
5232 Ok(trimmed.to_string())
5233}
5234
5235#[allow(clippy::large_futures, clippy::too_many_arguments)]
5236#[cfg(test)]
5237async fn handle_console_runtime_rpc(
5238 runtime: &MobRuntime,
5239 module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
5240 contact_directory: Option<&ContactDirectory>,
5241 gateway_peer_keys: Option<&crate::auth::peer_keys::GatewayPeerKeys>,
5242 console_events: Option<ConsoleEventStore>,
5243 console_aggregator: Option<MobKitConsoleAggregator>,
5244 identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
5245 metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
5246 mob_events: Option<MobEventsStore>,
5247 request: JsonRpcRequest,
5248 is_authenticated: bool,
5249) -> Value {
5250 handle_console_runtime_rpc_with_visibility(
5251 runtime,
5252 module_runtime,
5253 contact_directory,
5254 gateway_peer_keys,
5255 console_events,
5256 console_aggregator,
5257 identity_runtime,
5258 metadata_table,
5259 mob_events,
5260 &crate::console_aggregator::AllowAllConsoleVisibilityPolicy,
5261 request,
5262 is_authenticated,
5263 false,
5264 None,
5265 None,
5266 None,
5267 None,
5268 None,
5269 None,
5270 None,
5271 )
5272 .await
5273}
5274
5275#[allow(clippy::too_many_arguments)]
5276async fn handle_console_runtime_rpc_with_visibility(
5277 runtime: &MobRuntime,
5278 module_runtime: Option<std::sync::Arc<tokio::sync::Mutex<MobkitRuntimeHandle>>>,
5279 contact_directory: Option<&ContactDirectory>,
5280 gateway_peer_keys: Option<&crate::auth::peer_keys::GatewayPeerKeys>,
5281 console_events: Option<ConsoleEventStore>,
5282 console_aggregator: Option<MobKitConsoleAggregator>,
5283 identity_runtime: Option<Arc<crate::identity_first::IdentityRuntime>>,
5284 metadata_table: Option<std::sync::Arc<RuntimeMetadataTable>>,
5285 mob_events: Option<MobEventsStore>,
5286 visibility_policy: &dyn ConsoleVisibilityPolicy,
5287 request: JsonRpcRequest,
5288 is_authenticated: bool,
5289 read_only: bool,
5290 authenticated_principal: Option<&str>,
5291 access: Option<&AccessController>,
5292 access_view: Option<&AccessView>,
5293 memory_panel: Option<&dyn crate::memory::capabilities::MemoryPanelStore>,
5294 identity_roster: Option<Arc<crate::identity_first::MutableRosterProvider>>,
5295 workgraph: Option<&meerkat::WorkGraphService>,
5296 topology: Option<&crate::topology_control::TopologyRuntimeHandle>,
5297) -> Value {
5298 let response_id = request.id.clone().unwrap_or(Value::Null);
5299 if let Err(message) =
5300 crate::member_comms_id::validate_public_rpc_member_aliases(&request.params)
5301 {
5302 return invalid_params(response_id, message);
5303 }
5304 let can_mutate = is_authenticated && !read_only;
5305 if is_console_mutating_rpc_method(request.method.as_str()) && !can_mutate {
5306 return console_read_only_rpc_error(response_id);
5307 }
5308 if let Some(controller) = access.filter(|controller| controller.enabled()) {
5314 prime_access_cache_from_runtime(runtime, controller).await;
5315 }
5316 if let Some(response) = handle_access_admin_rpc(access, access_view, &request) {
5317 return response;
5318 }
5319 if let Some(error) =
5320 console_rpc_access_violation(access_view, request.method.as_str(), &request.params)
5321 {
5322 return response_value(response_id, None, Some(error));
5323 }
5324
5325 match request.method.as_str() {
5326 "mobkit/capabilities" => {
5327 let mut methods = vec![
5328 "mobkit/status",
5329 "mobkit/capabilities",
5330 "mobkit/list_members",
5331 "mobkit/get_member",
5332 "mobkit/find_members",
5333 "mobkit/member_status",
5334 "mobkit/identity/resolved_tools",
5335 "mobkit/blob/get",
5336 "mobkit/wait_ready",
5337 "mobkit/flow_status",
5338 "mobkit/list_flows",
5339 "mobkit/list_runs",
5340 "mobkit/console/list_identities",
5341 "mobkit/console/inspect_identity",
5342 "mobkit/console/query_timeline",
5343 "mobkit/mob_events/query",
5344 "mobkit/mob_events/subscribe",
5345 "mobkit/cross_mob/peer_info",
5346 "mobkit/cross_mob/directory",
5347 "mobkit/peer_pubkey",
5348 "mobkit/storage/doctor",
5349 ];
5350 if identity_runtime.is_some() {
5351 methods.extend_from_slice(&["mobkit/status_identity", "mobkit/inspect_identity"]);
5352 if let Some(identity_runtime) = &identity_runtime
5353 && identity_runtime.agent_memory_supports_recall().await
5354 {
5355 methods.push("mobkit/agent_memory/recall");
5356 if can_mutate && identity_runtime.agent_memory_supports_remember().await {
5357 methods.push("mobkit/agent_memory/remember");
5358 }
5359 if can_mutate && identity_runtime.agent_memory_supports_forget().await {
5360 methods.push("mobkit/agent_memory/forget");
5361 }
5362 if can_mutate && identity_runtime.agent_memory_supports_update().await {
5363 methods.push("mobkit/agent_memory/update");
5364 }
5365 if identity_runtime.agent_memory_supports_manifest().await {
5366 methods.push("mobkit/agent_memory/manifest");
5367 }
5368 }
5369 if can_mutate {
5370 methods.push("mobkit/delete_identity");
5371 }
5372 } else if console_aggregator.is_some() {
5373 methods.extend_from_slice(&["mobkit/status_identity", "mobkit/inspect_identity"]);
5374 }
5375 if module_runtime.is_some() {
5376 methods.extend_from_slice(&[
5377 "mobkit/routing/routes/list",
5378 "mobkit/delivery/history",
5379 "mobkit/gating/pending",
5380 "mobkit/gating/audit",
5381 ]);
5382 if can_mutate {
5383 methods.push("mobkit/gating/decide");
5384 }
5385 }
5386 if can_mutate {
5387 methods.extend_from_slice(&[
5388 "mobkit/retire",
5389 "mobkit/respawn",
5396 "mobkit/reset",
5397 "mobkit/reset_all",
5398 "mobkit/console/send",
5399 "mobkit/blob/upload",
5400 "mobkit/ensure_member",
5401 "mobkit/retire_member",
5402 "mobkit/respawn_member",
5403 "mobkit/force_cancel_member",
5404 "mobkit/cancel_flow",
5405 "mobkit/collect_completed",
5406 "mobkit/run_flow",
5407 "mobkit/spawn_helper",
5408 "mobkit/fork_helper",
5409 "mobkit/attach_existing_session",
5410 "mobkit/reconcile_edges",
5411 "mobkit/cross_mob/wire_local",
5412 "mobkit/cross_mob/unwire_local",
5413 ]);
5414 }
5415 if metadata_table.is_some() {
5416 methods.extend_from_slice(&["mobkit/mob_labels/get", "mobkit/run_labels/get"]);
5417 if can_mutate {
5418 methods.extend_from_slice(&[
5419 "mobkit/mob_labels/set",
5420 "mobkit/mob_labels/delete",
5421 "mobkit/run_labels/set",
5422 "mobkit/run_labels/delete",
5423 ]);
5424 }
5425 }
5426 if memory_panel.is_some() {
5427 methods.extend_from_slice(&[
5433 "mobkit/memory/panel/records",
5434 "mobkit/memory/panel/record",
5435 "mobkit/memory/panel/dreams",
5436 "mobkit/memory/panel/dream_runs",
5437 "mobkit/memory/panel/audit_verdicts",
5438 "mobkit/memory/panel/overview",
5439 "mobkit/memory/panel/proposals",
5440 "mobkit/memory/panel/injections",
5441 "mobkit/memory/panel/harvests",
5442 "mobkit/memory/panel/quarantine",
5443 ]);
5444 }
5445 if workgraph.is_some() {
5446 methods.extend_from_slice(crate::rpc::workgraph_methods::WORKGRAPH_READ_METHODS);
5447 if can_mutate {
5448 methods
5449 .extend_from_slice(crate::rpc::workgraph_methods::WORKGRAPH_MUTATE_METHODS);
5450 methods.extend_from_slice(
5451 crate::rpc::workgraph_methods::WORKGRAPH_CONSOLE_MUTATE_METHODS,
5452 );
5453 }
5454 }
5455 if access.is_some() {
5456 methods.push("mobkit/access/status");
5457 if access_view.is_some_and(AccessView::can_administer) {
5458 methods.extend_from_slice(&["mobkit/access/get", "mobkit/access/preview"]);
5459 if can_mutate {
5460 methods.extend_from_slice(&[
5461 "mobkit/access/set",
5462 "mobkit/access/enable",
5463 "mobkit/access/rules/upsert",
5464 "mobkit/access/rules/delete",
5465 "mobkit/access/groups/set",
5466 "mobkit/access/groups/delete",
5467 ]);
5468 }
5469 }
5470 }
5471 let topology_capabilities = topology.map(|topology| {
5472 let (topology_methods, capabilities) =
5473 crate::rpc::topology_methods::capability_projection(
5474 topology,
5475 access_view,
5476 !can_mutate,
5477 );
5478 methods.extend(topology_methods);
5479 capabilities
5480 });
5481 if let Some(view) = access_view.filter(|view| view.enforced()) {
5489 let probe = serde_json::json!({ "identity": "\u{0}cap-probe" });
5490 methods.retain(
5491 |method| match console_rpc_access_requirements(method, &probe) {
5492 Some(requirements) => requirements
5493 .iter()
5494 .all(|(action, target)| target.is_some() || view.allows(action)),
5495 None => true,
5496 },
5497 );
5498 }
5499 let cap = |action: &str| -> bool {
5502 can_mutate
5503 && access_view
5504 .filter(|view| view.enforced())
5505 .is_none_or(|view| view.may_perform_anywhere(action))
5506 };
5507 response_value(
5508 response_id,
5509 Some(serde_json::json!({
5510 "contract_version": crate::rpc::MOBKIT_CONTRACT_VERSION,
5511 "methods": methods,
5512 "identity_first": identity_runtime.is_some(),
5518 "workgraph": workgraph.is_some(),
5521 "read_only": read_only,
5522 "loaded_modules": serde_json::json!([]),
5525 "runtime_capabilities": {
5526 "can_send_messages": cap(ACTION_AGENT_SEND),
5527 "can_retire_members": cap(ACTION_AGENT_RETIRE),
5528 "can_spawn_members": cap(ACTION_AGENT_SPAWN),
5529 },
5530 "topology_control": topology_capabilities,
5531 })),
5532 None,
5533 )
5534 }
5535 crate::rpc::topology_methods::TOPOLOGY_QUERY_METHOD => {
5536 let Some(topology) = topology else {
5537 return response_value(
5538 response_id,
5539 None,
5540 Some(JsonRpcError {
5541 code: -32601,
5542 message: "Method not found".to_string(),
5543 data: None,
5544 }),
5545 );
5546 };
5547 serde_json::to_value(
5548 crate::rpc::topology_methods::handle_query(
5549 topology,
5550 response_id,
5551 access_view,
5552 !can_mutate,
5553 )
5554 .await,
5555 )
5556 .unwrap_or(Value::Null)
5557 }
5558 crate::rpc::topology_methods::TOPOLOGY_PLAN_METHOD => {
5559 let Some(topology) = topology else {
5560 return response_value(
5561 response_id,
5562 None,
5563 Some(JsonRpcError {
5564 code: -32601,
5565 message: "Method not found".to_string(),
5566 data: None,
5567 }),
5568 );
5569 };
5570 serde_json::to_value(
5571 crate::rpc::topology_methods::handle_plan(
5572 topology,
5573 response_id,
5574 &request.params,
5575 access_view,
5576 )
5577 .await,
5578 )
5579 .unwrap_or(Value::Null)
5580 }
5581 crate::rpc::topology_methods::TOPOLOGY_APPLY_METHOD => {
5582 let Some(topology) = topology else {
5583 return response_value(
5584 response_id,
5585 None,
5586 Some(JsonRpcError {
5587 code: -32601,
5588 message: "Method not found".to_string(),
5589 data: None,
5590 }),
5591 );
5592 };
5593 serde_json::to_value(
5594 crate::rpc::topology_methods::handle_apply(
5595 topology,
5596 response_id,
5597 &request.params,
5598 access_view,
5599 authenticated_principal,
5600 )
5601 .await,
5602 )
5603 .unwrap_or(Value::Null)
5604 }
5605 crate::rpc::topology_methods::TOPOLOGY_OPERATION_METHOD => {
5606 let Some(topology) = topology else {
5607 return response_value(
5608 response_id,
5609 None,
5610 Some(JsonRpcError {
5611 code: -32601,
5612 message: "Method not found".to_string(),
5613 data: None,
5614 }),
5615 );
5616 };
5617 serde_json::to_value(
5618 crate::rpc::topology_methods::handle_operation(
5619 topology,
5620 response_id,
5621 &request.params,
5622 access_view,
5623 )
5624 .await,
5625 )
5626 .unwrap_or(Value::Null)
5627 }
5628 crate::rpc::topology_methods::TOPOLOGY_AUDIT_METHOD => {
5629 let Some(topology) = topology else {
5630 return response_value(
5631 response_id,
5632 None,
5633 Some(JsonRpcError {
5634 code: -32601,
5635 message: "Method not found".to_string(),
5636 data: None,
5637 }),
5638 );
5639 };
5640 serde_json::to_value(
5641 crate::rpc::topology_methods::handle_audit(
5642 topology,
5643 response_id,
5644 &request.params,
5645 access_view,
5646 )
5647 .await,
5648 )
5649 .unwrap_or(Value::Null)
5650 }
5651 "mobkit/agent_memory/remember" => {
5652 let Some(identity_runtime) = &identity_runtime else {
5653 return response_value(
5654 response_id,
5655 None,
5656 Some(JsonRpcError {
5657 code: -32601,
5658 message: "agent memory is not configured".to_string(),
5659 data: None,
5660 }),
5661 );
5662 };
5663 match parse_agent_memory_remember_params(&request.params) {
5664 Ok(remember_request) => match identity_runtime
5665 .remember_agent_memory(
5666 &remember_request.realm,
5667 &remember_request.identity,
5668 remember_request.memory,
5669 )
5670 .await
5671 {
5672 Ok(record) => response_value(
5673 response_id,
5674 Some(serde_json::to_value(record).unwrap_or(Value::Null)),
5675 None,
5676 ),
5677 Err(err) => response_value(
5678 response_id,
5679 None,
5680 Some(crate::rpc::agent_memory_rpc_error("write", err)),
5681 ),
5682 },
5683 Err(err) => {
5684 invalid_params(response_id, format!("Invalid params: {}", err.message()))
5685 }
5686 }
5687 }
5688 "mobkit/agent_memory/forget" => {
5689 let Some(identity_runtime) = &identity_runtime else {
5690 return response_value(
5691 response_id,
5692 None,
5693 Some(JsonRpcError {
5694 code: -32601,
5695 message: "agent memory is not configured".to_string(),
5696 data: None,
5697 }),
5698 );
5699 };
5700 match parse_agent_memory_forget_params(&request.params) {
5701 Ok(forget_request) => match identity_runtime
5702 .forget_agent_memory(
5703 &forget_request.realm,
5704 &forget_request.identity,
5705 &forget_request.memory_id,
5706 )
5707 .await
5708 {
5709 Ok(result) => response_value(
5710 response_id,
5711 Some(serde_json::to_value(result).unwrap_or(Value::Null)),
5712 None,
5713 ),
5714 Err(err) => response_value(
5715 response_id,
5716 None,
5717 Some(crate::rpc::agent_memory_rpc_error("forget", err)),
5718 ),
5719 },
5720 Err(err) => {
5721 invalid_params(response_id, format!("Invalid params: {}", err.message()))
5722 }
5723 }
5724 }
5725 "mobkit/agent_memory/recall" => {
5726 let Some(identity_runtime) = &identity_runtime else {
5727 return response_value(
5728 response_id,
5729 None,
5730 Some(JsonRpcError {
5731 code: -32601,
5732 message: "agent memory is not configured".to_string(),
5733 data: None,
5734 }),
5735 );
5736 };
5737 match parse_agent_memory_recall_params(&request.params) {
5738 Ok(recall_request) => match identity_runtime
5739 .recall_agent_memory(recall_request.request)
5740 .await
5741 {
5742 Ok(records) => {
5743 response_value(response_id, Some(json!({ "records": records })), None)
5744 }
5745 Err(err) => response_value(
5746 response_id,
5747 None,
5748 Some(crate::rpc::agent_memory_rpc_error("recall", err)),
5749 ),
5750 },
5751 Err(err) => {
5752 invalid_params(response_id, format!("Invalid params: {}", err.message()))
5753 }
5754 }
5755 }
5756 "mobkit/agent_memory/update" => {
5757 let Some(identity_runtime) = &identity_runtime else {
5758 return response_value(
5759 response_id,
5760 None,
5761 Some(JsonRpcError {
5762 code: -32601,
5763 message: "agent memory is not configured".to_string(),
5764 data: None,
5765 }),
5766 );
5767 };
5768 match parse_agent_memory_update_params(&request.params) {
5769 Ok(update_request) => match identity_runtime
5770 .update_agent_memory(
5771 &update_request.realm,
5772 &update_request.identity,
5773 &update_request.memory_id,
5774 update_request.memory,
5775 )
5776 .await
5777 {
5778 Ok(new_id) => response_value(
5779 response_id,
5780 Some(json!({
5781 "memory_id": new_id,
5782 "supersedes": update_request.memory_id,
5783 })),
5784 None,
5785 ),
5786 Err(err) => response_value(
5787 response_id,
5788 None,
5789 Some(crate::rpc::agent_memory_rpc_error("update", err)),
5790 ),
5791 },
5792 Err(err) => {
5793 invalid_params(response_id, format!("Invalid params: {}", err.message()))
5794 }
5795 }
5796 }
5797 "mobkit/agent_memory/manifest" => {
5798 let Some(identity_runtime) = &identity_runtime else {
5799 return response_value(
5800 response_id,
5801 None,
5802 Some(JsonRpcError {
5803 code: -32601,
5804 message: "agent memory is not configured".to_string(),
5805 data: None,
5806 }),
5807 );
5808 };
5809 match parse_agent_memory_manifest_params(&request.params) {
5810 Ok(manifest_request) => match identity_runtime
5811 .manifest_agent_memory(
5812 &manifest_request.realm,
5813 &manifest_request.identity,
5814 manifest_request.tier,
5815 )
5816 .await
5817 {
5818 Ok(records) => {
5819 response_value(response_id, Some(json!({ "records": records })), None)
5820 }
5821 Err(err) => response_value(
5822 response_id,
5823 None,
5824 Some(crate::rpc::agent_memory_rpc_error("manifest", err)),
5825 ),
5826 },
5827 Err(err) => {
5828 invalid_params(response_id, format!("Invalid params: {}", err.message()))
5829 }
5830 }
5831 }
5832 "mobkit/memory/panel/records" => {
5837 handle_memory_panel_records(memory_panel, access_view, &request.params, response_id)
5838 .await
5839 }
5840 "mobkit/memory/panel/record" => {
5841 handle_memory_panel_record(memory_panel, access_view, &request.params, response_id)
5842 .await
5843 }
5844 "mobkit/memory/panel/quarantine" => {
5845 handle_memory_panel_quarantine(memory_panel, access_view, &request.params, response_id)
5846 .await
5847 }
5848 "mobkit/memory/panel/dreams" => {
5849 handle_memory_panel_dreams(memory_panel, &request.params, response_id).await
5850 }
5851 "mobkit/memory/panel/dream_runs" => {
5852 handle_memory_panel_dream_runs(memory_panel, &request.params, response_id).await
5853 }
5854 "mobkit/memory/panel/audit_verdicts" => {
5855 handle_memory_panel_audit_verdicts(memory_panel, &request.params, response_id).await
5856 }
5857 "mobkit/memory/panel/overview" => {
5858 handle_memory_panel_overview(memory_panel, &request.params, response_id).await
5859 }
5860 "mobkit/memory/panel/proposals" => {
5861 handle_memory_panel_proposals(memory_panel, &request.params, response_id).await
5862 }
5863 "mobkit/memory/panel/injections" => {
5864 handle_memory_panel_injections(memory_panel, &request.params, response_id).await
5865 }
5866 "mobkit/memory/panel/harvests" => {
5867 handle_memory_panel_harvests(memory_panel, &request.params, response_id).await
5868 }
5869 "mobkit/storage/doctor" => {
5873 match crate::rpc::storage_methods::parse_storage_doctor_params(&request.params) {
5874 Ok(Some(params)) => {
5875 let result = crate::rpc::storage_methods::run_storage_doctor(
5876 ¶ms,
5877 runtime.resolved_storage(),
5878 )
5879 .await;
5880 response_value(response_id, Some(result), None)
5881 }
5882 Ok(None) => response_value(
5883 response_id,
5884 None,
5885 Some(crate::rpc::storage_methods::storage_doctor_state_dir_unavailable_error()),
5886 ),
5887 Err(reason) => invalid_params(response_id, reason),
5888 }
5889 }
5890 "mobkit/status" => {
5891 let mob_state = runtime.handle().status_observation_snapshot();
5892 let mut result = serde_json::json!({
5893 "contract_version": crate::rpc::MOBKIT_CONTRACT_VERSION,
5894 "running": matches!(mob_state, MobState::Creating | MobState::Running),
5895 "loaded_modules": serde_json::json!([]),
5898 });
5899 if let Some(storage) = runtime.resolved_storage() {
5902 result["storage"] = storage.status_json();
5903 }
5904 result["storage"]["doctor_available"] = serde_json::json!(true);
5908 response_value(response_id, Some(result), None)
5909 }
5910 "mobkit/console/list_identities" => {
5911 let Some(aggregator) = &console_aggregator else {
5912 return response_value(
5913 response_id,
5914 None,
5915 Some(JsonRpcError {
5916 code: -32004,
5917 message: "console aggregator unavailable".to_string(),
5918 data: None,
5919 }),
5920 );
5921 };
5922 match Box::pin(aggregator.list_identities()).await {
5923 Ok(mut identities) => {
5924 retain_visible_identity_records(&mut identities, access_view);
5925 response_value(response_id, Some(json!({ "identities": identities })), None)
5926 }
5927 Err(err) => internal_error(response_id, format!("list_identities failed: {err}")),
5928 }
5929 }
5930 "mobkit/console/inspect_identity" => {
5931 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
5932 return invalid_params(response_id, "identity required");
5933 };
5934 let Some(aggregator) = &console_aggregator else {
5935 return response_value(
5936 response_id,
5937 None,
5938 Some(JsonRpcError {
5939 code: -32004,
5940 message: "console aggregator unavailable".to_string(),
5941 data: None,
5942 }),
5943 );
5944 };
5945 match Box::pin(aggregator.inspect_identity(identity)).await {
5946 Ok(Some(inspection)) => response_value(
5947 response_id,
5948 Some(serde_json::to_value(inspection).unwrap_or(Value::Null)),
5949 None,
5950 ),
5951 Ok(None) => response_value(
5952 response_id,
5953 None,
5954 Some(JsonRpcError {
5955 code: -32001,
5956 message: format!("unknown identity: {identity}"),
5957 data: None,
5958 }),
5959 ),
5960 Err(err) => internal_error(response_id, format!("inspect_identity failed: {err}")),
5961 }
5962 }
5963 "mobkit/console/query_timeline" => {
5964 let query: ConsoleTimelineWindowQuery =
5965 match serde_json::from_value(request.params.clone()) {
5966 Ok(query) => query,
5967 Err(err) => {
5968 return invalid_params(response_id, format!("invalid query params: {err}"));
5969 }
5970 };
5971 let Some(aggregator) = &console_aggregator else {
5972 return response_value(
5973 response_id,
5974 None,
5975 Some(JsonRpcError {
5976 code: -32004,
5977 message: "console aggregator unavailable".to_string(),
5978 data: None,
5979 }),
5980 );
5981 };
5982 match Box::pin(aggregator.query_timeline_windowed(query.clone())).await {
5983 Ok(mut page) => {
5984 retain_visible_timeline_frames(&mut page, access_view);
5985 response_value(
5986 response_id,
5987 Some(serde_json::to_value(page).unwrap_or(Value::Null)),
5988 None,
5989 )
5990 }
5991 Err(err) => {
5992 let latest_cursor = aggregator.latest_cursor().await.ok().flatten();
5993 console_timeline_replay_unavailable_response(
5994 response_id,
5995 err,
5996 query.after.as_ref(),
5997 latest_cursor,
5998 )
5999 }
6000 }
6001 }
6002 "mobkit/console/send" => {
6003 let send_request: ConsoleSendRequest =
6004 match serde_json::from_value(request.params.clone()) {
6005 Ok(request) => request,
6006 Err(err) => {
6007 return invalid_params(response_id, format!("invalid send params: {err}"));
6008 }
6009 };
6010 let Some(aggregator) = &console_aggregator else {
6011 return response_value(
6012 response_id,
6013 None,
6014 Some(JsonRpcError {
6015 code: -32004,
6016 message: "console aggregator unavailable".to_string(),
6017 data: None,
6018 }),
6019 );
6020 };
6021 if let Some(identity_runtime) = &identity_runtime {
6022 return match Box::pin(console_send_with_identity_first_fallback(
6023 aggregator,
6024 identity_runtime.clone(),
6025 Some(runtime),
6026 console_events.as_ref(),
6027 send_request,
6028 ))
6029 .await
6030 {
6031 Ok(accepted) => response_value(
6032 response_id,
6033 Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
6034 None,
6035 ),
6036 Err(err) => {
6037 response_value(response_id, None, Some(console_send_json_rpc_error(err)))
6038 }
6039 };
6040 }
6041 match Box::pin(aggregator.send(send_request)).await {
6042 Ok(accepted) => response_value(
6043 response_id,
6044 Some(serde_json::to_value(accepted).unwrap_or(Value::Null)),
6045 None,
6046 ),
6047 Err(err) => {
6048 response_value(response_id, None, Some(console_send_json_rpc_error(err)))
6049 }
6050 }
6051 }
6052 "mobkit/blob/get" => {
6053 let Some(blob_id) = request
6054 .params
6055 .get("blob_id")
6056 .or_else(|| request.params.get("id"))
6057 .and_then(Value::as_str)
6058 else {
6059 return invalid_params(response_id, "blob_id required");
6060 };
6061 if !is_valid_blob_id_value(blob_id) {
6062 return invalid_params(response_id, "invalid blob_id");
6063 }
6064 let Some(store) = runtime.binary_blob_store() else {
6065 return internal_error(response_id, "binary blob store unavailable");
6066 };
6067 match store.get_bytes(&meerkat_core::BlobId::from(blob_id)).await {
6068 Ok(payload) => response_value(
6069 response_id,
6070 Some(serde_json::json!({
6071 "blob_id": payload.blob_id,
6072 "media_type": payload.media_type,
6073 "size": payload.size,
6074 "data": base64::engine::general_purpose::STANDARD.encode(payload.data.as_ref()),
6075 })),
6076 None,
6077 ),
6078 Err(meerkat_core::BlobStoreError::NotFound(_)) => response_value(
6079 response_id,
6080 None,
6081 Some(JsonRpcError {
6082 code: -32001,
6083 message: format!("blob not found: {blob_id}"),
6084 data: Some(json!({ "kind": "not_found", "blob_id": blob_id })),
6085 }),
6086 ),
6087 Err(err) => internal_error(response_id, format!("blob get failed: {err}")),
6088 }
6089 }
6090 "mobkit/list_members" => {
6091 let handle = runtime.handle();
6092 let entries = handle.list_members_including_retiring().await;
6093 let mut members = Vec::with_capacity(entries.len());
6094 for entry in &entries {
6095 let alias =
6096 crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str());
6097 if stale_runtime_alias_json_rpc_error(
6098 "list_members",
6099 identity_runtime.as_ref(),
6100 alias.as_ref(),
6101 )
6102 .await
6103 .is_some()
6104 {
6105 continue;
6106 }
6107 members.push(member_entry_to_console_json(runtime, entry).await);
6108 }
6109 retain_visible_member_rows(&mut members, access_view);
6110 response_value(response_id, Some(Value::Array(members)), None)
6111 }
6112 "mobkit/get_member" => {
6113 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
6114 return invalid_params(response_id, "member_id required");
6115 };
6116 if let Some(error) = stale_runtime_alias_json_rpc_error(
6117 "get_member",
6118 identity_runtime.as_ref(),
6119 member_id,
6120 )
6121 .await
6122 {
6123 return response_value(response_id, None, Some(error));
6124 }
6125 let handle = runtime.handle();
6126 let identity = crate::member_comms_id::mob_member_id(member_id);
6127 let entries = handle.list_members_including_retiring().await;
6128 match entries.into_iter().find(|e| e.agent_identity == identity) {
6129 Some(entry) => response_value(
6130 response_id,
6131 Some(member_entry_to_console_json(runtime, &entry).await),
6132 None,
6133 ),
6134 None => invalid_params(response_id, format!("member not found: {member_id}")),
6135 }
6136 }
6137 "mobkit/find_members" => {
6138 let Some(label_key) = request.params.get("label_key").and_then(Value::as_str) else {
6139 return invalid_params(response_id, "label_key required");
6140 };
6141 let Some(label_value) = request.params.get("label_value").and_then(Value::as_str)
6142 else {
6143 return invalid_params(response_id, "label_value required");
6144 };
6145 let handle = runtime.handle();
6146 let filter = MemberFilter {
6147 labels: std::collections::BTreeMap::from([(
6148 label_key.to_string(),
6149 label_value.to_string(),
6150 )]),
6151 role: None,
6152 status: None,
6153 };
6154 let entries = match handle.list_members_matching(filter).await {
6155 Ok(entries) => entries,
6156 Err(err) => {
6157 return invalid_params(response_id, format!("member lookup failed: {err}"));
6158 }
6159 };
6160 let mut matches = Vec::with_capacity(entries.len());
6161 for entry in &entries {
6162 let alias =
6163 crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str());
6164 if stale_runtime_alias_json_rpc_error(
6165 "find_members",
6166 identity_runtime.as_ref(),
6167 alias.as_ref(),
6168 )
6169 .await
6170 .is_some()
6171 {
6172 continue;
6173 }
6174 matches.push(member_entry_to_console_json(runtime, entry).await);
6175 }
6176 retain_visible_member_rows(&mut matches, access_view);
6177 response_value(response_id, Some(Value::Array(matches)), None)
6178 }
6179 "mobkit/status_identity" => {
6180 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6181 return invalid_params(response_id, "identity required");
6182 };
6183 let handle = runtime.handle();
6184 if let Some(identity_runtime) = &identity_runtime {
6185 let (parsed_identity, _was_registered, live_alias) =
6186 match resolve_console_identity_control_target(
6187 &handle,
6188 Some(identity_runtime),
6189 visibility_policy,
6190 identity,
6191 )
6192 .await
6193 {
6194 Ok(Some(target)) => target,
6195 Ok(None) => {
6196 return invalid_params(
6197 response_id,
6198 format!("identity not found: {identity}"),
6199 );
6200 }
6201 Err(err) => return response_value(response_id, None, Some(err)),
6202 };
6203 match identity_runtime.status(&parsed_identity).await {
6204 Ok(status) => {
6205 if !identity_status_visible_to_console(visibility_policy, &status) {
6206 return identity_hidden_by_policy_response(response_id, identity);
6207 }
6208 let phase = if let Some(store) = &console_events {
6209 store
6210 .response_phase_for_identity(status.identity.as_str())
6211 .await
6212 } else {
6213 None
6214 };
6215 if let Some(error) = stale_live_alias_json_rpc_error(
6216 "status_identity",
6217 identity_runtime,
6218 &parsed_identity,
6219 live_alias.as_ref(),
6220 )
6221 .await
6222 {
6223 return response_value(response_id, None, Some(error));
6224 }
6225 return response_value(
6226 response_id,
6227 Some(console_identity_status_json_from_identity_status(
6228 &status, phase,
6229 )),
6230 None,
6231 );
6232 }
6233 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
6234 Err(err) => {
6235 return console_identity_error_response(
6236 response_id,
6237 "status_identity",
6238 err,
6239 );
6240 }
6241 }
6242 }
6243 if let Some(aggregator) = &console_aggregator {
6244 return match Box::pin(aggregator.inspect_identity(identity)).await {
6245 Ok(Some(inspection)) => {
6246 let phase = if let Some(store) = &console_events {
6247 store
6248 .response_phase_for_identity(&inspection.identity.identity)
6249 .await
6250 } else {
6251 None
6252 };
6253 response_value(
6254 response_id,
6255 Some(console_identity_status_json_from_record(
6256 &inspection.identity,
6257 phase,
6258 )),
6259 None,
6260 )
6261 }
6262 Ok(None) => response_value(
6263 response_id,
6264 None,
6265 Some(JsonRpcError {
6266 code: -32001,
6267 message: format!("unknown identity: {identity}"),
6268 data: None,
6269 }),
6270 ),
6271 Err(err) => {
6272 internal_error(response_id, format!("status_identity failed: {err}"))
6273 }
6274 };
6275 }
6276 let live_alias = match lookup_member_alias_with_session(
6277 &handle,
6278 visibility_policy,
6279 identity,
6280 )
6281 .await
6282 {
6283 Ok(alias) => alias,
6284 Err(err) => return response_value(response_id, None, Some(err)),
6285 };
6286 let Some(alias) = live_alias else {
6287 return invalid_params(response_id, format!("identity not found: {identity}"));
6288 };
6289 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6290 return identity_hidden_by_policy_response(response_id, identity);
6291 }
6292 if let Err(err) =
6293 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
6294 {
6295 return response_value(response_id, None, Some(err));
6296 }
6297 let phase = if let Some(store) = &console_events {
6298 store.response_phase_for_identity(&alias.identity).await
6299 } else {
6300 None
6301 };
6302 response_value(
6303 response_id,
6304 Some(console_identity_status_json_for_identity(
6305 &alias.identity,
6306 &alias.member,
6307 alias.session_id,
6308 phase,
6309 )),
6310 None,
6311 )
6312 }
6313 "mobkit/inspect_identity" => {
6314 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6315 return invalid_params(response_id, "identity required");
6316 };
6317 let handle = runtime.handle();
6318 if let Some(identity_runtime) = &identity_runtime {
6319 let (parsed_identity, _was_registered, live_alias) =
6320 match resolve_console_identity_control_target(
6321 &handle,
6322 Some(identity_runtime),
6323 visibility_policy,
6324 identity,
6325 )
6326 .await
6327 {
6328 Ok(Some(target)) => target,
6329 Ok(None) => {
6330 return invalid_params(
6331 response_id,
6332 format!("identity not found: {identity}"),
6333 );
6334 }
6335 Err(err) => return response_value(response_id, None, Some(err)),
6336 };
6337 match identity_runtime.status(&parsed_identity).await {
6338 Ok(status) => {
6339 if !identity_status_visible_to_console(visibility_policy, &status) {
6340 return identity_hidden_by_policy_response(response_id, identity);
6341 }
6342 let phase = if let Some(store) = &console_events {
6343 store
6344 .response_phase_for_identity(status.identity.as_str())
6345 .await
6346 } else {
6347 None
6348 };
6349 if let Some(error) = stale_live_alias_json_rpc_error(
6350 "inspect_identity",
6351 identity_runtime,
6352 &parsed_identity,
6353 live_alias.as_ref(),
6354 )
6355 .await
6356 {
6357 return response_value(response_id, None, Some(error));
6358 }
6359 return response_value(
6360 response_id,
6361 Some(console_identity_inspect_json_from_identity_status(
6362 &status,
6363 live_alias.as_ref(),
6364 phase,
6365 )),
6366 None,
6367 );
6368 }
6369 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {}
6370 Err(err) => {
6371 return console_identity_error_response(
6372 response_id,
6373 "inspect_identity",
6374 err,
6375 );
6376 }
6377 }
6378 }
6379 if let Some(aggregator) = &console_aggregator {
6380 return match Box::pin(aggregator.inspect_identity(identity)).await {
6381 Ok(Some(inspection)) => {
6382 let phase = if let Some(store) = &console_events {
6383 store
6384 .response_phase_for_identity(&inspection.identity.identity)
6385 .await
6386 } else {
6387 None
6388 };
6389 response_value(
6390 response_id,
6391 Some(console_identity_inspect_json_from_record(
6392 &inspection,
6393 phase,
6394 )),
6395 None,
6396 )
6397 }
6398 Ok(None) => response_value(
6399 response_id,
6400 None,
6401 Some(JsonRpcError {
6402 code: -32001,
6403 message: format!("unknown identity: {identity}"),
6404 data: None,
6405 }),
6406 ),
6407 Err(err) => {
6408 internal_error(response_id, format!("inspect_identity failed: {err}"))
6409 }
6410 };
6411 }
6412 let live_alias = match lookup_member_alias_with_session(
6413 &handle,
6414 visibility_policy,
6415 identity,
6416 )
6417 .await
6418 {
6419 Ok(alias) => alias,
6420 Err(err) => return response_value(response_id, None, Some(err)),
6421 };
6422 let Some(alias) = live_alias else {
6423 return invalid_params(response_id, format!("identity not found: {identity}"));
6424 };
6425 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6426 return identity_hidden_by_policy_response(response_id, identity);
6427 }
6428 if let Err(err) =
6429 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
6430 {
6431 return response_value(response_id, None, Some(err));
6432 }
6433 let phase = if let Some(store) = &console_events {
6434 store.response_phase_for_identity(&alias.identity).await
6435 } else {
6436 None
6437 };
6438 response_value(
6439 response_id,
6440 Some(console_identity_inspect_json_for_identity(
6441 &alias.identity,
6442 &alias.member,
6443 alias.session_id,
6444 phase,
6445 )),
6446 None,
6447 )
6448 }
6449 "mobkit/retire" => {
6450 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6451 return invalid_params(response_id, "identity required");
6452 };
6453 let handle = runtime.handle();
6454 if let Some(identity_runtime) = &identity_runtime {
6455 let (parsed_identity, was_registered, live_alias) =
6456 match resolve_console_identity_control_target(
6457 &handle,
6458 Some(identity_runtime),
6459 visibility_policy,
6460 identity,
6461 )
6462 .await
6463 {
6464 Ok(Some(target)) => target,
6465 Ok(None) => {
6466 return invalid_params(
6467 response_id,
6468 format!("identity not found: {identity}"),
6469 );
6470 }
6471 Err(err) => return response_value(response_id, None, Some(err)),
6472 };
6473 let registered_status = match identity_runtime.status(&parsed_identity).await {
6474 Ok(status) => Some(status),
6475 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => None,
6476 Err(err) => {
6477 return console_identity_error_response(response_id, "retire", err);
6478 }
6479 };
6480 if let Some(status) = registered_status.as_ref() {
6481 if !identity_status_visible_to_console(visibility_policy, status) {
6482 return identity_hidden_by_policy_response(response_id, identity);
6483 }
6484 if let Some(error) = stale_live_alias_json_rpc_error(
6485 "retire",
6486 identity_runtime,
6487 &parsed_identity,
6488 live_alias.as_ref(),
6489 )
6490 .await
6491 {
6492 return response_value(response_id, None, Some(error));
6493 }
6494 }
6495 let cleanup_handle = handle.clone();
6496 let cleanup_identity = parsed_identity.clone();
6497 let include_current = !identity_runtime.has_session_bridge();
6498 let expected_alias = crate::member_comms_id::is_reserved_generated_alias(identity)
6499 .then_some(identity);
6500 let retire_result = identity_runtime
6501 .retire_and_cleanup_live_members_tracked(
6502 &parsed_identity,
6503 expected_alias,
6504 move |retired_alias| async move {
6505 let stale_member_ids = match retired_alias.as_ref() {
6506 Some(retired_alias) => {
6507 stale_console_member_ids_for_identity_authoritative(
6508 &cleanup_handle,
6509 cleanup_identity.as_str(),
6510 retired_alias.as_str(),
6511 include_current,
6512 )
6513 .await
6514 }
6515 None => Vec::new(),
6516 };
6517 retire_console_member_ids(&cleanup_handle, stale_member_ids)
6518 .await
6519 .err()
6520 .map(|error| {
6521 json!({
6522 "kind": "stale_member_cleanup_failed_after_identity_retire",
6523 "identity": cleanup_identity.as_str(),
6524 "message": error,
6525 })
6526 })
6527 },
6528 )
6529 .await;
6530 match retire_result {
6531 Ok((token, cleanup_warning)) => {
6532 if let Some(store) = &console_events {
6533 store
6534 .record_lifecycle(
6535 parsed_identity.as_str(),
6536 "identity_retired",
6537 json!({
6538 "fencing_token": token.get(),
6539 "cleanup_warning": cleanup_warning.clone(),
6540 }),
6541 )
6542 .await;
6543 }
6544 return response_value(
6545 response_id,
6546 Some(json!({
6547 "identity": parsed_identity.as_str(),
6548 "fencing_token": token.get(),
6549 "cleanup_warning": cleanup_warning,
6550 })),
6551 None,
6552 );
6553 }
6554 Err(err @ crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6555 if !console_live_only_fallback_allowed(
6556 was_registered,
6557 identity,
6558 live_alias.as_ref(),
6559 ) {
6560 return console_identity_error_response(response_id, "retire", err);
6561 }
6562 if let Some(alias) = live_alias.as_ref() {
6563 if !runtime_alias_visible_to_console(&handle, visibility_policy, alias)
6564 {
6565 return identity_hidden_by_policy_response(response_id, identity);
6566 }
6567 let mid = crate::member_comms_id::mob_member_id(
6568 alias.runtime_member_id.as_str(),
6569 );
6570 return match retire_console_member(&handle, &mid).await {
6571 Ok(()) => {
6572 if let Some(store) = &console_events {
6573 store
6574 .record_lifecycle(
6575 &alias.identity,
6576 "identity_retired",
6577 json!({}),
6578 )
6579 .await;
6580 }
6581 response_value(
6582 response_id,
6583 Some(json!({ "identity": alias.identity })),
6584 None,
6585 )
6586 }
6587 Err(err) => {
6588 internal_error(response_id, format!("retire failed: {err}"))
6589 }
6590 };
6591 }
6592 }
6593 Err(err) => return console_identity_error_response(response_id, "retire", err),
6594 }
6595 }
6596 if let Some(aggregator) = &console_aggregator {
6597 let canonical_identity = match Box::pin(aggregator.inspect_identity(identity)).await
6598 {
6599 Ok(Some(inspection)) => inspection.identity.identity,
6600 Ok(None) => identity.to_string(),
6601 Err(_) => identity.to_string(),
6602 };
6603 return match Box::pin(aggregator.retire_identity(identity)).await {
6604 Ok(true) => {
6605 if let Some(store) = &console_events {
6606 store
6607 .record_lifecycle(
6608 &canonical_identity,
6609 "identity_retired",
6610 json!({}),
6611 )
6612 .await;
6613 }
6614 response_value(
6615 response_id,
6616 Some(json!({ "identity": canonical_identity })),
6617 None,
6618 )
6619 }
6620 Ok(false) => response_value(
6621 response_id,
6622 None,
6623 Some(JsonRpcError {
6624 code: -32001,
6625 message: format!("unknown identity: {identity}"),
6626 data: None,
6627 }),
6628 ),
6629 Err(err) => internal_error(response_id, format!("retire failed: {err}")),
6630 };
6631 }
6632 let live_alias = match lookup_member_alias_with_session(
6633 &handle,
6634 visibility_policy,
6635 identity,
6636 )
6637 .await
6638 {
6639 Ok(alias) => alias,
6640 Err(err) => return response_value(response_id, None, Some(err)),
6641 };
6642 let Some(alias) = live_alias else {
6643 return invalid_params(response_id, format!("identity not found: {identity}"));
6644 };
6645 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6646 return identity_hidden_by_policy_response(response_id, identity);
6647 }
6648 if let Err(err) =
6649 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
6650 {
6651 return response_value(response_id, None, Some(err));
6652 }
6653 let mid = crate::member_comms_id::mob_member_id(alias.runtime_member_id.as_str());
6654 match retire_console_member(&handle, &mid).await {
6655 Ok(()) => {
6656 if let Some(store) = &console_events {
6657 store
6658 .record_lifecycle(&alias.identity, "identity_retired", json!({}))
6659 .await;
6660 }
6661 response_value(
6662 response_id,
6663 Some(json!({ "identity": alias.identity })),
6664 None,
6665 )
6666 }
6667 Err(err) => internal_error(response_id, format!("retire failed: {err}")),
6668 }
6669 }
6670 "mobkit/respawn" => {
6671 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6672 return invalid_params(response_id, "identity required");
6673 };
6674 let handle = runtime.handle();
6675 if let Some(identity_runtime) = &identity_runtime {
6676 let (parsed_identity, was_registered, live_alias) =
6677 match resolve_console_identity_control_target(
6678 &handle,
6679 Some(identity_runtime),
6680 visibility_policy,
6681 identity,
6682 )
6683 .await
6684 {
6685 Ok(Some(target)) => target,
6686 Ok(None) => {
6687 return invalid_params(
6688 response_id,
6689 format!("identity not found: {identity}"),
6690 );
6691 }
6692 Err(err) => return response_value(response_id, None, Some(err)),
6693 };
6694 let registered_status = match identity_runtime.status(&parsed_identity).await {
6695 Ok(status) => Some(status),
6696 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => None,
6697 Err(err) => {
6698 return console_identity_error_response(response_id, "respawn", err);
6699 }
6700 };
6701 if let Some(status) = registered_status.as_ref() {
6702 if !identity_status_visible_to_console(visibility_policy, status) {
6703 return identity_hidden_by_policy_response(response_id, identity);
6704 }
6705 if let Some(error) = stale_live_alias_json_rpc_error(
6706 "respawn",
6707 identity_runtime,
6708 &parsed_identity,
6709 live_alias.as_ref(),
6710 )
6711 .await
6712 {
6713 return response_value(response_id, None, Some(error));
6714 }
6715 }
6716 let expected_alias = crate::member_comms_id::is_reserved_generated_alias(identity)
6717 .then_some(identity);
6718 let respawn_result = identity_runtime
6719 .respawn_identity_in_place_tracked(&parsed_identity, expected_alias)
6720 .await;
6721 match respawn_result {
6722 Ok(record) => {
6723 let live_respawn_warning: Option<Value> = None;
6727 let cleanup_warning: Option<Value> = None;
6728 if let Some(store) = &console_events {
6729 store
6730 .record_lifecycle(
6731 parsed_identity.as_str(),
6732 "identity_respawned",
6733 json!({
6734 "generation": record.generation.get(),
6735 "checkpoint_version": record.checkpoint_version.get(),
6736 "live_respawn_warning": live_respawn_warning.clone(),
6737 "cleanup_warning": cleanup_warning.clone(),
6738 }),
6739 )
6740 .await;
6741 }
6742 return response_value(
6743 response_id,
6744 Some(json!({
6745 "identity": record.identity.as_str(),
6746 "agent_runtime_id": record.agent_runtime_id.as_str(),
6747 "session_id": record.session_id.to_string(),
6748 "generation": record.generation.get(),
6749 "checkpoint_version": record.checkpoint_version.get(),
6750 "live_respawn_warning": live_respawn_warning,
6751 "cleanup_warning": cleanup_warning,
6752 })),
6753 None,
6754 );
6755 }
6756 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6757 if !console_live_only_fallback_allowed(
6758 was_registered,
6759 identity,
6760 live_alias.as_ref(),
6761 ) {
6762 return invalid_params(
6763 response_id,
6764 format!("identity not found: {identity}"),
6765 );
6766 }
6767 }
6768 Err(err) => {
6769 return console_identity_error_response(response_id, "respawn", err);
6770 }
6771 }
6772 }
6773 let live_alias = match lookup_member_alias_with_session(
6774 &handle,
6775 visibility_policy,
6776 identity,
6777 )
6778 .await
6779 {
6780 Ok(alias) => alias,
6781 Err(err) => return response_value(response_id, None, Some(err)),
6782 };
6783 let Some(alias) = live_alias else {
6784 return invalid_params(response_id, format!("identity not found: {identity}"));
6785 };
6786 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6787 return identity_hidden_by_policy_response(response_id, identity);
6788 }
6789 if let Err(err) =
6790 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias).await
6791 {
6792 return response_value(response_id, None, Some(err));
6793 }
6794 Box::pin(respawn_live_console_member_response(
6795 &handle,
6796 console_events.as_ref(),
6797 &alias,
6798 "identity_respawned",
6799 "respawn",
6800 response_id,
6801 ))
6802 .await
6803 }
6804 "mobkit/reset" => {
6805 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6806 return invalid_params(response_id, "identity required");
6807 };
6808 let handle = runtime.handle();
6809 let Some(identity_runtime) = &identity_runtime else {
6810 let live_alias =
6816 match lookup_member_alias_with_session(&handle, visibility_policy, identity)
6817 .await
6818 {
6819 Ok(alias) => alias,
6820 Err(err) => return response_value(response_id, None, Some(err)),
6821 };
6822 let Some(alias) = live_alias else {
6823 return invalid_params(response_id, format!("identity not found: {identity}"));
6824 };
6825 if !runtime_alias_visible_to_console(&handle, visibility_policy, &alias) {
6826 return identity_hidden_by_policy_response(response_id, identity);
6827 }
6828 if let Err(err) =
6829 reject_ambiguous_projected_live_identity(&handle, visibility_policy, &alias)
6830 .await
6831 {
6832 return response_value(response_id, None, Some(err));
6833 }
6834 return Box::pin(respawn_live_console_member_response(
6835 &handle,
6836 console_events.as_ref(),
6837 &alias,
6838 "identity_reset",
6839 "reset",
6840 response_id,
6841 ))
6842 .await;
6843 };
6844 let (parsed_identity, was_registered, live_alias) =
6845 match resolve_console_identity_control_target(
6846 &handle,
6847 Some(identity_runtime),
6848 visibility_policy,
6849 identity,
6850 )
6851 .await
6852 {
6853 Ok(Some(target)) => target,
6854 Ok(None) => {
6855 return invalid_params(
6856 response_id,
6857 format!("identity not found: {identity}"),
6858 );
6859 }
6860 Err(err) => return response_value(response_id, None, Some(err)),
6861 };
6862 match identity_runtime.status(&parsed_identity).await {
6863 Ok(status) => {
6864 if !identity_status_visible_to_console(visibility_policy, &status) {
6865 return identity_hidden_by_policy_response(response_id, identity);
6866 }
6867 if let Some(error) = stale_live_alias_json_rpc_error(
6868 "reset",
6869 identity_runtime,
6870 &parsed_identity,
6871 live_alias.as_ref(),
6872 )
6873 .await
6874 {
6875 return response_value(response_id, None, Some(error));
6876 }
6877 if !identity_runtime.has_session_bridge() {
6878 return response_value(
6879 response_id,
6880 None,
6881 Some(reset_requires_session_bridge_json_rpc_error()),
6882 );
6883 }
6884 }
6885 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6886 if console_live_only_fallback_allowed(
6887 was_registered,
6888 identity,
6889 live_alias.as_ref(),
6890 ) && let Some(alias) = live_alias.as_ref()
6891 {
6892 if !runtime_alias_visible_to_console(&handle, visibility_policy, alias) {
6893 return identity_hidden_by_policy_response(response_id, identity);
6894 }
6895 return Box::pin(respawn_live_console_member_response(
6896 &handle,
6897 console_events.as_ref(),
6898 alias,
6899 "identity_reset",
6900 "reset",
6901 response_id,
6902 ))
6903 .await;
6904 }
6905 return invalid_params(response_id, format!("identity not found: {identity}"));
6906 }
6907 Err(err) => return console_identity_error_response(response_id, "reset", err),
6908 }
6909 let reset_result = if crate::member_comms_id::is_reserved_generated_alias(identity) {
6910 identity_runtime
6911 .reset_member_alias_tracked(&parsed_identity, identity)
6912 .await
6913 } else {
6914 identity_runtime.reset_tracked(&parsed_identity).await
6915 };
6916 match reset_result {
6917 Ok(record) => {
6918 let cleanup_warning = Some(json!({
6919 "kind": "stale_member_cleanup_skipped_after_identity_reset",
6920 "identity": parsed_identity.as_str(),
6921 "agent_runtime_id": record.agent_runtime_id.as_str(),
6922 "message": "reset published the new generation without retiring stale live mob members; identity control calls reject stale runtime ids",
6923 }));
6924 if let Some(store) = &console_events {
6925 store
6926 .record_lifecycle(
6927 parsed_identity.as_str(),
6928 "identity_reset",
6929 json!({
6930 "generation": record.generation.get(),
6931 "checkpoint_version": record.checkpoint_version.get(),
6932 "cleanup_warning": cleanup_warning.clone(),
6933 }),
6934 )
6935 .await;
6936 }
6937 response_value(
6938 response_id,
6939 Some(json!({
6940 "identity": record.identity.as_str(),
6941 "agent_runtime_id": record.agent_runtime_id.as_str(),
6942 "session_id": record.session_id.to_string(),
6943 "generation": record.generation.get(),
6944 "checkpoint_version": record.checkpoint_version.get(),
6945 "cleanup_warning": cleanup_warning,
6946 })),
6947 None,
6948 )
6949 }
6950 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6951 invalid_params(response_id, format!("identity not found: {identity}"))
6952 }
6953 Err(err) => console_identity_error_response(response_id, "reset", err),
6954 }
6955 }
6956 "mobkit/delete_identity" => {
6957 let Some(identity) = request.params.get("identity").and_then(Value::as_str) else {
6958 return invalid_params(response_id, "identity required");
6959 };
6960 let handle = runtime.handle();
6961 let Some(identity_runtime) = &identity_runtime else {
6962 return invalid_params(
6963 response_id,
6964 "identity-first runtime required for delete_identity",
6965 );
6966 };
6967 let (parsed_identity, was_registered, live_alias) =
6968 match resolve_console_identity_control_target(
6969 &handle,
6970 Some(identity_runtime),
6971 visibility_policy,
6972 identity,
6973 )
6974 .await
6975 {
6976 Ok(Some(target)) => target,
6977 Ok(None) => {
6978 return invalid_params(
6979 response_id,
6980 format!("identity not found: {identity}"),
6981 );
6982 }
6983 Err(err) => return response_value(response_id, None, Some(err)),
6984 };
6985 let registered_status = match identity_runtime.status(&parsed_identity).await {
6986 Ok(status) => status,
6987 Err(err @ crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
6988 if was_registered {
6989 return console_identity_error_response(
6990 response_id,
6991 "delete_identity",
6992 err,
6993 );
6994 }
6995 if let Some(alias) = live_alias.as_ref() {
6996 if !runtime_alias_visible_to_console(&handle, visibility_policy, alias) {
6997 return identity_hidden_by_policy_response(response_id, identity);
6998 }
6999 return response_value(
7000 response_id,
7001 None,
7002 Some(JsonRpcError {
7003 code: -32602,
7004 message: format!(
7005 "delete_identity requires durable identity: {} is live-only",
7006 parsed_identity.as_str()
7007 ),
7008 data: Some(json!({
7009 "kind": "live_only_identity_delete_unsupported",
7010 "identity": parsed_identity.as_str(),
7011 })),
7012 }),
7013 );
7014 }
7015 return invalid_params(response_id, format!("identity not found: {identity}"));
7016 }
7017 Err(err) => {
7018 return console_identity_error_response(response_id, "delete_identity", err);
7019 }
7020 };
7021 if !identity_status_visible_to_console(visibility_policy, ®istered_status) {
7022 return identity_hidden_by_policy_response(response_id, identity);
7023 }
7024 if let Some(error) = stale_live_alias_json_rpc_error(
7025 "delete_identity",
7026 identity_runtime,
7027 &parsed_identity,
7028 live_alias.as_ref(),
7029 )
7030 .await
7031 {
7032 return response_value(response_id, None, Some(error));
7033 }
7034 let cleanup_handle = handle.clone();
7035 let cleanup_identity = parsed_identity.clone();
7036 let include_current = !identity_runtime.has_session_bridge();
7037 let expected_alias =
7038 crate::member_comms_id::is_reserved_generated_alias(identity).then_some(identity);
7039 let delete_result = identity_runtime
7040 .delete_identity_and_cleanup_live_members_tracked(
7041 &parsed_identity,
7042 expected_alias,
7043 move |deleted_alias| async move {
7044 let stale_member_ids = match deleted_alias.as_ref() {
7045 Some(deleted_alias) => {
7046 stale_console_member_ids_for_identity_authoritative(
7047 &cleanup_handle,
7048 cleanup_identity.as_str(),
7049 deleted_alias.as_str(),
7050 include_current,
7051 )
7052 .await
7053 }
7054 None => Vec::new(),
7055 };
7056 retire_console_member_ids(&cleanup_handle, stale_member_ids)
7057 .await
7058 .err()
7059 .map(|error| {
7060 json!({
7061 "kind": "stale_member_cleanup_failed_after_identity_delete",
7062 "identity": cleanup_identity.as_str(),
7063 "message": error,
7064 })
7065 })
7066 },
7067 )
7068 .await;
7069 match delete_result {
7070 Ok(cleanup_warning) => {
7071 if let Some(store) = &console_events {
7072 store
7073 .record_lifecycle(
7074 parsed_identity.as_str(),
7075 "identity_deleted",
7076 json!({
7077 "cleanup_warning": cleanup_warning.clone(),
7078 }),
7079 )
7080 .await;
7081 }
7082 response_value(
7083 response_id,
7084 Some(json!({
7085 "identity": parsed_identity.as_str(),
7086 "cleanup_warning": cleanup_warning,
7087 })),
7088 None,
7089 )
7090 }
7091 Err(err) => console_identity_error_response(response_id, "delete_identity", err),
7092 }
7093 }
7094 "mobkit/reset_all" => {
7095 match Box::pin(reset_all_live_console_agents(
7096 runtime,
7097 console_events.as_ref(),
7098 console_aggregator.as_ref(),
7099 identity_runtime.as_ref(),
7100 visibility_policy,
7101 ))
7102 .await
7103 {
7104 Ok(body) => {
7105 if body
7106 .get("failed")
7107 .and_then(Value::as_array)
7108 .is_some_and(|failed| !failed.is_empty())
7109 {
7110 response_value(
7111 response_id,
7112 None,
7113 Some(JsonRpcError {
7114 code: -32000,
7115 message: "reset_all failed for one or more identities".to_string(),
7116 data: Some(body),
7117 }),
7118 )
7119 } else {
7120 response_value(response_id, Some(body), None)
7121 }
7122 }
7123 Err(err) => internal_error(response_id, format!("reset_all failed: {err}")),
7124 }
7125 }
7126 "mobkit/routing/routes/list" => {
7127 let Some(module_runtime) = &module_runtime else {
7128 return response_value(
7129 response_id,
7130 None,
7131 Some(JsonRpcError {
7132 code: -32601,
7133 message: "Method not found".to_string(),
7134 data: None,
7135 }),
7136 );
7137 };
7138 let routes = module_runtime.lock().await.list_runtime_routes();
7139 response_value(response_id, Some(json!({ "routes": routes })), None)
7140 }
7141 "mobkit/delivery/history" => {
7142 let Some(module_runtime) = &module_runtime else {
7143 return response_value(
7144 response_id,
7145 None,
7146 Some(JsonRpcError {
7147 code: -32601,
7148 message: "Method not found".to_string(),
7149 data: None,
7150 }),
7151 );
7152 };
7153 let limit = request
7154 .params
7155 .get("limit")
7156 .and_then(Value::as_u64)
7157 .unwrap_or(50) as usize;
7158 let history = module_runtime
7159 .lock()
7160 .await
7161 .delivery_history(DeliveryHistoryRequest {
7162 recipient: None,
7163 sink: None,
7164 limit,
7165 });
7166 response_value(
7167 response_id,
7168 Some(serde_json::to_value(history).unwrap_or(Value::Null)),
7169 None,
7170 )
7171 }
7172 "mobkit/gating/pending" => {
7173 let Some(module_runtime) = &module_runtime else {
7174 return response_value(
7175 response_id,
7176 None,
7177 Some(JsonRpcError {
7178 code: -32601,
7179 message: "Method not found".to_string(),
7180 data: None,
7181 }),
7182 );
7183 };
7184 let pending = module_runtime.lock().await.list_gating_pending();
7185 response_value(response_id, Some(json!({ "pending": pending })), None)
7186 }
7187 "mobkit/gating/audit" => {
7188 let Some(module_runtime) = &module_runtime else {
7189 return response_value(
7190 response_id,
7191 None,
7192 Some(JsonRpcError {
7193 code: -32601,
7194 message: "Method not found".to_string(),
7195 data: None,
7196 }),
7197 );
7198 };
7199 let limit = request
7200 .params
7201 .get("limit")
7202 .and_then(Value::as_u64)
7203 .unwrap_or(50) as usize;
7204 let entries = module_runtime.lock().await.gating_audit_entries(limit);
7205 response_value(response_id, Some(json!({ "entries": entries })), None)
7206 }
7207 "mobkit/gating/decide" => {
7208 let Some(module_runtime) = &module_runtime else {
7209 return response_value(
7210 response_id,
7211 None,
7212 Some(JsonRpcError {
7213 code: -32601,
7214 message: "Method not found".to_string(),
7215 data: None,
7216 }),
7217 );
7218 };
7219 let Some(pending_id) = request.params.get("pending_id").and_then(Value::as_str) else {
7220 return invalid_params(response_id, "pending_id required");
7221 };
7222 let approver_id =
7223 match resolve_gating_approver_id(&request.params, authenticated_principal) {
7224 Ok(approver_id) => approver_id,
7225 Err(message) => return invalid_params(response_id, message),
7226 };
7227 let Some(raw_decision) = request.params.get("decision").and_then(Value::as_str) else {
7228 return invalid_params(response_id, "decision required");
7229 };
7230 let decision = match raw_decision {
7231 "approve" => GatingDecision::Approve,
7232 "reject" | "deny" => GatingDecision::Reject,
7233 "escalate" => GatingDecision::Escalate,
7234 _ => {
7235 return invalid_params(
7236 response_id,
7237 format!("unsupported decision: {raw_decision}"),
7238 );
7239 }
7240 };
7241 let reason = request
7242 .params
7243 .get("reason")
7244 .and_then(Value::as_str)
7245 .map(ToString::to_string);
7246 match module_runtime
7247 .lock()
7248 .await
7249 .decide_gating_action(GatingDecideRequest {
7250 pending_id: pending_id.to_string(),
7251 approver_id,
7252 decision,
7253 reason,
7254 }) {
7255 Ok(result) => response_value(
7256 response_id,
7257 Some(serde_json::to_value(result).unwrap_or(Value::Null)),
7258 None,
7259 ),
7260 Err(err) => gating_decision_failed_error(response_id, err),
7261 }
7262 }
7263 "mobkit/ensure_member" => {
7264 let Some(role) = request.params.get("role").and_then(Value::as_str) else {
7265 return invalid_params(response_id, "role required");
7266 };
7267 let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
7268 else {
7269 return invalid_params(response_id, "agent_identity required");
7270 };
7271 if crate::member_comms_id::is_reserved_generated_alias(agent_identity) {
7272 return invalid_params(
7273 response_id,
7274 "rt:* is reserved for identity-runtime generated aliases",
7275 );
7276 }
7277 let labels = match request.params.get("labels") {
7278 None | Some(Value::Null) => std::collections::BTreeMap::new(),
7279 Some(value) => match serde_json::from_value(value.clone()) {
7280 Ok(map) => map,
7281 Err(err) => {
7282 return invalid_params(response_id, format!("invalid labels: {err}"));
7283 }
7284 },
7285 };
7286 if let Err(message) = crate::member_comms_id::validate_raw_identity_labels(&labels) {
7287 return invalid_params(response_id, message);
7288 }
7289 let context = request.params.get("context").cloned();
7290 let resume_session_id = match request.params.get("resume_session_id") {
7291 None => None,
7292 Some(Value::Null) => None,
7293 Some(v) => match v.as_str() {
7294 Some(s) => match meerkat_core::types::SessionId::parse(s) {
7295 Ok(sid) => Some(sid),
7296 Err(_) => {
7297 return invalid_params(
7298 response_id,
7299 format!("invalid resume_session_id: {s:?}"),
7300 );
7301 }
7302 },
7303 None => {
7304 return invalid_params(
7305 response_id,
7306 "resume_session_id must be a string".to_string(),
7307 );
7308 }
7309 },
7310 };
7311 let additional_instructions = match request.params.get("additional_instructions") {
7312 None | Some(Value::Null) => None,
7313 Some(Value::Array(arr)) => {
7314 let mut strs = Vec::with_capacity(arr.len());
7315 for (i, entry) in arr.iter().enumerate() {
7316 match entry.as_str() {
7317 Some(s) => strs.push(s.to_string()),
7318 None => {
7319 return invalid_params(
7320 response_id,
7321 format!("additional_instructions[{i}] must be a string"),
7322 );
7323 }
7324 }
7325 }
7326 if strs.is_empty() { None } else { Some(strs) }
7327 }
7328 Some(_) => {
7329 return invalid_params(
7330 response_id,
7331 "additional_instructions must be an array of strings",
7332 );
7333 }
7334 };
7335 let runtime_mode = match parse_optional_runtime_mode(&request.params) {
7336 Ok(value) => value,
7337 Err(message) => return invalid_params(response_id, message),
7338 };
7339 let backend = match parse_optional_backend(&request.params) {
7340 Ok(value) => value,
7341 Err(message) => return invalid_params(response_id, message),
7342 };
7343 let binding = match parse_optional_runtime_binding(&request.params) {
7344 Ok(value) => value,
7345 Err(message) => return invalid_params(response_id, message),
7346 };
7347 let worker_plane = matches!(
7356 request.params.get("plane").and_then(Value::as_str),
7357 Some("worker")
7358 );
7359 if !worker_plane
7360 && let (Some(identity_runtime_ref), Some(roster)) =
7361 (identity_runtime.as_ref(), identity_roster.as_ref())
7362 {
7363 let identity = match crate::identity_first::AgentIdentity::parse(agent_identity) {
7364 Ok(identity) => identity,
7365 Err(err) => {
7366 return invalid_params(
7367 response_id,
7368 format!("invalid agent_identity: {err}"),
7369 );
7370 }
7371 };
7372 if resume_session_id.is_some() {
7373 return invalid_params(
7374 response_id,
7375 "resume_session_id is not supported on an identity-first \
7376 gateway: continuity records own session resumption",
7377 );
7378 }
7379 if binding.is_some() {
7380 return invalid_params(
7381 response_id,
7382 "external runtime bindings are not supported on an \
7383 identity-first gateway yet",
7384 );
7385 }
7386 let spec = crate::identity_first::DurableAgentSpec {
7387 identity: identity.clone(),
7388 profile: ProfileName::from(role),
7389 addressability: crate::identity_first::AgentAddressability::Addressable,
7390 display_name: None,
7391 labels,
7392 context,
7393 additional_instructions: additional_instructions.unwrap_or_default(),
7394 initial_message: None,
7395 runtime_mode_override: runtime_mode,
7396 backend,
7397 binding: None,
7398 };
7399 roster.upsert(spec);
7400 return match crate::identity_first::restore_flow(
7401 identity_runtime_ref,
7402 &roster.snapshot(),
7403 None,
7404 None,
7405 )
7406 .await
7407 {
7408 Ok(result) => {
7409 let _ = reconcile_console_topology(runtime, topology).await;
7413 let outcome = match result.outcomes.get(&identity) {
7414 Some(crate::identity_first::RestoreOutcome::Created { .. }) => {
7415 "created"
7416 }
7417 Some(crate::identity_first::RestoreOutcome::Resumed { .. }) => {
7418 "resumed"
7419 }
7420 Some(crate::identity_first::RestoreOutcome::Dormant { .. }) => {
7421 "dormant"
7422 }
7423 Some(crate::identity_first::RestoreOutcome::Broken(_)) => "broken",
7424 None => "unchanged",
7425 };
7426 let state = identity_runtime_ref
7427 .status(&identity)
7428 .await
7429 .map(|status| status.state.wire_str().to_string())
7430 .unwrap_or_else(|_| "unknown".to_string());
7431 response_value(
7432 response_id,
7433 Some(serde_json::json!({
7434 "agent_identity": identity.as_str(),
7435 "role": role,
7436 "identity_first": true,
7437 "outcome": outcome,
7438 "state": state,
7439 })),
7440 None,
7441 )
7442 }
7443 Err(err) => {
7444 internal_error(response_id, format!("ensure_member (identity): {err}"))
7445 }
7446 };
7447 }
7448 let raw_reservation = match crate::member_comms_id::reserve_raw_member_target(
7449 identity_runtime.as_ref(),
7450 agent_identity,
7451 )
7452 .await
7453 {
7454 Ok(reservation) => reservation,
7455 Err(message) => return invalid_params(response_id, message),
7456 };
7457 let mut spec = SpawnMemberSpec::new(
7458 ProfileName::from(role),
7459 crate::member_comms_id::mob_member_id(raw_reservation.alias()),
7460 );
7461 if let Some(runtime_mode) = runtime_mode {
7462 spec = spec.with_runtime_mode(runtime_mode);
7463 }
7464 if let Some(backend) = backend {
7465 spec = spec.with_backend(backend);
7466 }
7467 if let Some(binding) = binding {
7468 spec.binding = Some(binding);
7469 }
7470 if !labels.is_empty() {
7471 spec = spec.with_labels(labels);
7472 }
7473 if let Some(ctx) = context {
7474 spec = spec.with_context(ctx);
7475 }
7476 if let Some(sid) = resume_session_id {
7477 spec = spec.with_resume_bridge_session_id(sid);
7478 }
7479 if let Some(instructions) = additional_instructions {
7480 spec = spec.with_additional_instructions(instructions);
7481 }
7482 let handle = runtime.handle();
7483 let mid = spec.identity.clone();
7484 let ensure_result = handle.ensure_member(spec).await;
7485 drop(raw_reservation);
7486 match ensure_result {
7487 Ok(_outcome) => {
7488 let _ = reconcile_console_topology(runtime, topology).await;
7489 let body = match lookup_member_with_session(&handle, &mid).await {
7490 Some((entry, _sid)) => member_entry_to_json(&entry),
7491 None => Value::Null,
7492 };
7493 response_value(response_id, Some(body), None)
7494 }
7495 Err(err) => internal_error(response_id, format!("ensure_member failed: {err}")),
7496 }
7497 }
7498 "mobkit/retire_member" => {
7499 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
7500 return invalid_params(response_id, "member_id required");
7501 };
7502 let member_id = crate::member_comms_id::runtime_alias_str(member_id).into_owned();
7503 if let Some(error) = stale_runtime_alias_json_rpc_error(
7504 "retire_member",
7505 identity_runtime.as_ref(),
7506 &member_id,
7507 )
7508 .await
7509 {
7510 return response_value(response_id, None, Some(error));
7511 }
7512 if let (Some(identity_runtime_ref), Some(roster)) =
7513 (identity_runtime.as_ref(), identity_roster.as_ref())
7514 && let Some(identity) = identity_runtime_ref
7515 .identity_for_member_mutation(&member_id)
7516 .await
7517 {
7518 return match identity_runtime_ref
7519 .retire_member_alias_tracked(&identity, &member_id)
7520 .await
7521 {
7522 Ok(_token) => {
7523 roster.remove(&identity);
7526 response_value(
7527 response_id,
7528 Some(serde_json::json!({
7529 "accepted": true,
7530 "identity_first": true,
7531 })),
7532 None,
7533 )
7534 }
7535 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
7536 response_value(
7537 response_id,
7538 None,
7539 Some(JsonRpcError {
7540 code: -32001,
7541 message: format!("unknown identity: {member_id}"),
7542 data: None,
7543 }),
7544 )
7545 }
7546 Err(err) => {
7547 internal_error(response_id, format!("retire_member (identity): {err}"))
7548 }
7549 };
7550 }
7551 if let Some(identity_runtime_ref) = identity_runtime.as_ref()
7555 && let Some(durable) = identity_runtime_ref
7556 .identity_for_member_mutation(&member_id)
7557 .await
7558 {
7559 return match identity_runtime_ref
7560 .retire_member_alias_tracked(&durable, &member_id)
7561 .await
7562 {
7563 Ok(_token) => response_value(
7564 response_id,
7565 Some(serde_json::json!({
7566 "accepted": true,
7567 "identity_first": true,
7568 })),
7569 None,
7570 ),
7571 Err(err) => {
7572 internal_error(response_id, format!("retire_member (identity): {err}"))
7573 }
7574 };
7575 }
7576 if crate::member_comms_id::is_reserved_generated_alias(&member_id) {
7577 return internal_error(
7578 response_id,
7579 format!(
7580 "generated member alias requires current identity authority: {member_id}"
7581 ),
7582 );
7583 }
7584 if let Some(aggregator) = &console_aggregator {
7585 return match Box::pin(aggregator.retire_identity(&member_id)).await {
7586 Ok(true) => response_value(
7587 response_id,
7588 Some(serde_json::json!({ "accepted": true })),
7589 None,
7590 ),
7591 Ok(false) => response_value(
7592 response_id,
7593 None,
7594 Some(JsonRpcError {
7595 code: -32001,
7596 message: format!("unknown identity: {member_id}"),
7597 data: None,
7598 }),
7599 ),
7600 Err(err) => internal_error(response_id, format!("retire_member failed: {err}")),
7601 };
7602 }
7603 match retire_console_member(
7606 &runtime.handle(),
7607 &crate::member_comms_id::mob_member_id(&member_id),
7608 )
7609 .await
7610 {
7611 Ok(()) => response_value(
7612 response_id,
7613 Some(serde_json::json!({ "accepted": true })),
7614 None,
7615 ),
7616 Err(err) => internal_error(response_id, format!("retire_member failed: {err}")),
7617 }
7618 }
7619 "mobkit/respawn_member" => {
7620 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
7621 return invalid_params(response_id, "member_id required");
7622 };
7623 let member_id = crate::member_comms_id::runtime_alias_str(member_id).into_owned();
7624 if let Some(error) = stale_runtime_alias_json_rpc_error(
7625 "respawn_member",
7626 identity_runtime.as_ref(),
7627 &member_id,
7628 )
7629 .await
7630 {
7631 return response_value(response_id, None, Some(error));
7632 }
7633 if identity_roster.is_some()
7634 && let Some(identity_runtime_ref) = identity_runtime.as_ref()
7635 && let Some(identity) = identity_runtime_ref
7636 .identity_for_member_mutation(&member_id)
7637 .await
7638 {
7639 return match identity_runtime_ref
7643 .reset_member_alias_tracked(&identity, &member_id)
7644 .await
7645 {
7646 Ok(record) => response_value(
7647 response_id,
7648 Some(serde_json::json!({
7649 "accepted": true,
7650 "identity_first": true,
7651 "session_id": record.session_id.to_string(),
7652 "generation": record.generation.get(),
7653 })),
7654 None,
7655 ),
7656 Err(crate::identity_first::IdentityRuntimeError::UnknownIdentity(_)) => {
7657 response_value(
7658 response_id,
7659 None,
7660 Some(JsonRpcError {
7661 code: -32001,
7662 message: format!("unknown identity: {member_id}"),
7663 data: None,
7664 }),
7665 )
7666 }
7667 Err(err) => {
7668 internal_error(response_id, format!("respawn_member (identity): {err}"))
7669 }
7670 };
7671 }
7672 if let Some(identity_runtime_ref) = identity_runtime.as_ref()
7673 && let Some(durable) = identity_runtime_ref
7674 .identity_for_member_mutation(&member_id)
7675 .await
7676 {
7677 return match identity_runtime_ref
7678 .reset_member_alias_tracked(&durable, &member_id)
7679 .await
7680 {
7681 Ok(record) => response_value(
7682 response_id,
7683 Some(serde_json::json!({
7684 "accepted": true,
7685 "identity_first": true,
7686 "session_id": record.session_id.to_string(),
7687 "generation": record.generation.get(),
7688 })),
7689 None,
7690 ),
7691 Err(err) => {
7692 internal_error(response_id, format!("respawn_member (identity): {err}"))
7693 }
7694 };
7695 }
7696 if crate::member_comms_id::is_reserved_generated_alias(&member_id) {
7697 return internal_error(
7698 response_id,
7699 format!(
7700 "generated member alias requires current identity authority: {member_id}"
7701 ),
7702 );
7703 }
7704 match Box::pin(respawn_console_member(
7707 &runtime.handle(),
7708 &crate::member_comms_id::mob_member_id(&member_id),
7709 ))
7710 .await
7711 {
7712 Ok(topology_restore_warning) => {
7713 let mut body = serde_json::json!({ "accepted": true });
7714 if let Some(warning) = topology_restore_warning {
7715 body["topology_restore_warning"] = warning;
7716 }
7717 response_value(response_id, Some(body), None)
7718 }
7719 Err(err) => internal_error(response_id, format!("respawn_member failed: {err}")),
7720 }
7721 }
7722 "mobkit/reconcile_edges" => {
7723 let report = reconcile_console_topology(runtime, topology).await;
7728 response_value(
7729 response_id,
7730 Some(serde_json::to_value(&report).unwrap_or(serde_json::Value::Null)),
7731 None,
7732 )
7733 }
7734 "mobkit/mob_events/query" | "mobkit/mob_events/subscribe" => {
7735 let query: EventQuery = if request.params.is_null() {
7736 EventQuery::default()
7737 } else {
7738 match serde_json::from_value(request.params.clone()) {
7739 Ok(q) => q,
7740 Err(err) => {
7741 return invalid_params(response_id, format!("invalid query params: {err}"));
7742 }
7743 }
7744 };
7745 let Some(store) = mob_events.as_ref() else {
7746 return response_value(
7747 response_id,
7748 Some(serde_json::json!({
7749 "events": [],
7750 "next_after_seq": Value::Null,
7751 })),
7752 None,
7753 );
7754 };
7755 let events_view = runtime.handle().events();
7756 let latest_at_handshake = events_view.latest_cursor().await.unwrap_or(0);
7760 let handle = runtime.handle();
7761 let result = crate::unified_runtime::mob_events::query_ledger_with_filter_selected(
7762 &events_view,
7763 store,
7764 &query,
7765 {
7766 let handle = handle.clone();
7767 move |event| {
7768 let handle = handle.clone();
7769 async move {
7770 crate::http_sse::project_structural_envelope_for_console(
7771 &handle,
7772 Some(runtime),
7773 visibility_policy,
7774 access_view,
7775 event,
7776 true,
7777 )
7778 .await
7779 }
7780 }
7781 },
7782 )
7783 .await;
7784 match result {
7785 Ok(page) => {
7786 let events = page.items;
7797 let resume_after_seq = Some(page.resume_after_seq);
7798 let body = if request.method == "mobkit/mob_events/subscribe" {
7799 let subscribe_url = crate::unified_runtime::mob_events::build_subscribe_url(
7800 &query,
7801 resume_after_seq,
7802 latest_at_handshake,
7803 );
7804 serde_json::json!({
7805 "stream": "mob_events",
7806 "events": events,
7807 "next_after_seq": resume_after_seq,
7808 "subscribe_url": subscribe_url,
7809 "keep_alive": {
7810 "interval_ms": 15_000_u64,
7811 "event": "keep_alive",
7812 },
7813 })
7814 } else {
7815 serde_json::json!({
7816 "events": events,
7817 "next_after_seq": resume_after_seq,
7818 })
7819 };
7820 response_value(response_id, Some(body), None)
7821 }
7822 Err(crate::unified_runtime::mob_events::MobEventsQueryError::Stale {
7823 after_cursor,
7824 latest_cursor,
7825 }) => stale_event_cursor_response(response_id, after_cursor, latest_cursor),
7826 Err(err) => internal_error(response_id, format!("mob_events query failed: {err}")),
7827 }
7828 }
7829 "mobkit/member_status" => {
7831 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
7832 return invalid_params(response_id, "member_id required");
7833 };
7834 let member_id = crate::member_comms_id::runtime_alias_str(member_id).into_owned();
7835 if let Some(error) = stale_runtime_alias_json_rpc_error(
7836 "member_status",
7837 identity_runtime.as_ref(),
7838 &member_id,
7839 )
7840 .await
7841 {
7842 return response_value(response_id, None, Some(error));
7843 }
7844 match runtime
7845 .handle()
7846 .member_status(&crate::member_comms_id::mob_member_id(&member_id))
7847 .await
7848 {
7849 Ok(snapshot) => response_value(
7850 response_id,
7851 Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
7852 None,
7853 ),
7854 Err(err) => internal_error(response_id, format!("member_status failed: {err}")),
7855 }
7856 }
7857 "mobkit/identity/resolved_tools" => {
7858 let Some(identity) = request
7859 .params
7860 .get("identity")
7861 .or_else(|| request.params.get("member_id"))
7862 .and_then(Value::as_str)
7863 else {
7864 return invalid_params(response_id, "identity required");
7865 };
7866 let identity = crate::member_comms_id::runtime_alias_str(identity).into_owned();
7867 if let Some(error) = stale_runtime_alias_json_rpc_error(
7868 "identity_resolved_tools",
7869 identity_runtime.as_ref(),
7870 &identity,
7871 )
7872 .await
7873 {
7874 return response_value(response_id, None, Some(error));
7875 }
7876 if let Some(identity_runtime) = identity_runtime.as_ref()
7877 && let Ok(parsed) = crate::identity_first::AgentIdentity::parse(&identity)
7878 && let Ok(status) = identity_runtime.status(&parsed).await
7879 && let Some(session_id) = status.session_id
7880 {
7881 match resolved_tools_for_session(runtime.session_service(), &identity, session_id)
7882 .await
7883 {
7884 Ok(snapshot) => {
7885 return response_value(
7886 response_id,
7887 Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
7888 None,
7889 );
7890 }
7891 Err(err) => {
7892 return internal_error(
7893 response_id,
7894 format!("resolved_tools failed: {err}"),
7895 );
7896 }
7897 }
7898 }
7899 match resolved_tools_for_member(&runtime.handle(), runtime.session_service(), &identity)
7900 .await
7901 {
7902 Ok(snapshot) => response_value(
7903 response_id,
7904 Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
7905 None,
7906 ),
7907 Err(err) => internal_error(response_id, format!("resolved_tools failed: {err}")),
7908 }
7909 }
7910 "mobkit/force_cancel_member" => {
7911 let Some(member_id) = request.params.get("member_id").and_then(Value::as_str) else {
7912 return invalid_params(response_id, "member_id required");
7913 };
7914 let member_id = crate::member_comms_id::runtime_alias_str(member_id).into_owned();
7915 if let Some(error) = stale_runtime_alias_json_rpc_error(
7916 "force_cancel_member",
7917 identity_runtime.as_ref(),
7918 &member_id,
7919 )
7920 .await
7921 {
7922 return response_value(response_id, None, Some(error));
7923 }
7924 let result = if let Some(identity_runtime_ref) = identity_runtime.as_ref()
7925 && let Some(identity) = identity_runtime_ref
7926 .identity_for_member_mutation(&member_id)
7927 .await
7928 {
7929 let handle = runtime.handle();
7930 let member_id_value = crate::member_comms_id::mob_member_id(&member_id);
7931 identity_runtime_ref
7932 .run_member_alias_operation_tracked(&identity, &member_id, move || async move {
7933 handle
7934 .force_cancel_member(member_id_value)
7935 .await
7936 .map_err(|error| error.to_string())
7937 })
7938 .await
7939 .map_err(|error| error.to_string())
7940 } else if crate::member_comms_id::is_reserved_generated_alias(&member_id) {
7941 Err(format!(
7942 "generated member alias requires current identity authority: {member_id}"
7943 ))
7944 } else {
7945 runtime
7946 .handle()
7947 .force_cancel_member(crate::member_comms_id::mob_member_id(&member_id))
7948 .await
7949 .map_err(|error| error.to_string())
7950 };
7951 match result {
7952 Ok(()) => response_value(
7953 response_id,
7954 Some(serde_json::json!({ "accepted": true })),
7955 None,
7956 ),
7957 Err(err) => {
7958 internal_error(response_id, format!("force_cancel_member failed: {err}"))
7959 }
7960 }
7961 }
7962 "mobkit/wait_ready" => {
7963 let timeout = Some(
7967 request
7968 .params
7969 .get("timeout_ms")
7970 .and_then(Value::as_u64)
7971 .map(std::time::Duration::from_millis)
7972 .unwrap_or(crate::unified_runtime::mob_ops::DEFAULT_WAIT_READY_TIMEOUT),
7973 );
7974 match runtime.handle().wait_for_ready(timeout).await {
7975 Ok(ready) => {
7976 let entries: Vec<Value> = ready
7977 .into_iter()
7978 .filter_map(|(identity, snapshot)| {
7982 let identity = identity.to_string();
7983 let visible = access_view
7984 .filter(|view| view.enforced())
7985 .is_none_or(|view| view.can_view_agent(&identity));
7986 visible.then(|| {
7987 serde_json::json!({
7988 "agent_identity": identity,
7989 "snapshot": serde_json::to_value(&snapshot)
7990 .unwrap_or(Value::Null),
7991 })
7992 })
7993 })
7994 .collect();
7995 response_value(
7996 response_id,
7997 Some(serde_json::json!({
7998 "ready": entries,
7999 "timeout": false,
8000 })),
8001 None,
8002 )
8003 }
8004 Err(err) => {
8005 if crate::unified_runtime::mob_ops::is_ready_wait_timeout(&err) {
8006 response_value(
8007 response_id,
8008 Some(serde_json::json!({
8009 "ready": Vec::<Value>::new(),
8010 "timeout": true,
8011 })),
8012 None,
8013 )
8014 } else {
8015 internal_error(response_id, format!("wait_for_ready failed: {err}"))
8016 }
8017 }
8018 }
8019 }
8020 "mobkit/collect_completed" => {
8021 let completed = runtime.handle().collect_completed().await;
8022 let entries: Vec<Value> = completed
8023 .into_iter()
8024 .filter_map(|(mid, snapshot)| {
8027 let mid = mid.to_string();
8028 let visible = access_view
8029 .filter(|view| view.enforced())
8030 .is_none_or(|view| view.can_view_agent(&mid));
8031 visible.then(|| {
8032 serde_json::json!({
8033 "member_id": mid,
8034 "snapshot": serde_json::to_value(&snapshot).unwrap_or(Value::Null),
8035 })
8036 })
8037 })
8038 .collect();
8039 response_value(
8040 response_id,
8041 Some(serde_json::json!({ "completed": entries })),
8042 None,
8043 )
8044 }
8045 "mobkit/cancel_flow" => {
8046 let Some(run_id) = request.params.get("run_id").and_then(Value::as_str) else {
8047 return invalid_params(response_id, "run_id required");
8048 };
8049 let run_id: meerkat_mob::RunId = match run_id.parse() {
8050 Ok(id) => id,
8051 Err(_) => return invalid_params(response_id, "invalid run_id format"),
8052 };
8053 match runtime.handle().cancel_flow(run_id).await {
8054 Ok(()) => response_value(
8055 response_id,
8056 Some(serde_json::json!({ "accepted": true })),
8057 None,
8058 ),
8059 Err(err) => internal_error(response_id, format!("cancel_flow failed: {err}")),
8060 }
8061 }
8062 "mobkit/flow_status" => {
8063 let Some(run_id) = request.params.get("run_id").and_then(Value::as_str) else {
8064 return invalid_params(response_id, "run_id required");
8065 };
8066 let run_id: meerkat_mob::RunId = match run_id.parse() {
8067 Ok(id) => id,
8068 Err(_) => return invalid_params(response_id, "invalid run_id format"),
8069 };
8070 match runtime.handle().flow_status(run_id).await {
8071 Ok(Some(mob_run)) => response_value(
8072 response_id,
8073 Some(serde_json::to_value(&mob_run).unwrap_or(Value::Null)),
8074 None,
8075 ),
8076 Ok(None) => response_value(response_id, Some(Value::Null), None),
8077 Err(err) => internal_error(response_id, format!("flow_status failed: {err}")),
8078 }
8079 }
8080 "mobkit/list_flows" => {
8081 let flows: Vec<String> = runtime
8082 .handle()
8083 .list_flows()
8084 .into_iter()
8085 .map(|id| id.to_string())
8086 .collect();
8087 response_value(
8088 response_id,
8089 Some(serde_json::json!({ "flows": flows })),
8090 None,
8091 )
8092 }
8093 "mobkit/list_runs" => {
8094 let flow_id = request
8095 .params
8096 .get("flow_id")
8097 .and_then(Value::as_str)
8098 .filter(|value| !value.is_empty())
8099 .map(meerkat_mob::FlowId::from);
8100 match runtime.handle().list_runs(flow_id.as_ref()).await {
8101 Ok(runs) => response_value(
8102 response_id,
8103 Some(serde_json::json!({
8104 "runs": serde_json::to_value(&runs).unwrap_or(Value::Null),
8105 })),
8106 None,
8107 ),
8108 Err(err) => internal_error(response_id, format!("list_runs failed: {err}")),
8109 }
8110 }
8111 "mobkit/run_flow" => {
8112 let Some(flow_id_str) = request.params.get("flow_id").and_then(Value::as_str) else {
8113 return invalid_params(response_id, "flow_id required");
8114 };
8115 if flow_id_str.is_empty() {
8116 return invalid_params(response_id, "flow_id required");
8117 }
8118 let flow_id = meerkat_mob::FlowId::from(flow_id_str);
8119 let flow_params = request.params.get("params").cloned().unwrap_or(Value::Null);
8120 if let Some(identity_runtime) = &identity_runtime
8121 && let Err(err) = identity_runtime.materialize_all_required_tracked().await
8122 {
8123 return internal_error(
8124 response_id,
8125 format!("identity-first flow materialization failed: {err}"),
8126 );
8127 }
8128 match Box::pin(runtime.handle().run_flow(flow_id, flow_params)).await {
8129 Ok(run_id) => response_value(
8130 response_id,
8131 Some(serde_json::json!({ "run_id": run_id.to_string() })),
8132 None,
8133 ),
8134 Err(err) => invalid_params(response_id, format!("run_flow failed: {err}")),
8135 }
8136 }
8137 "mobkit/spawn_helper" => {
8138 let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
8139 else {
8140 return invalid_params(response_id, "agent_identity required");
8141 };
8142 let Some(task) = request.params.get("task").and_then(Value::as_str) else {
8143 return invalid_params(response_id, "task required");
8144 };
8145 let options = match parse_console_helper_options(request.params.get("options")) {
8146 Ok(opts) => opts,
8147 Err(msg) => return invalid_params(response_id, msg),
8148 };
8149 let raw_reservation = match crate::member_comms_id::reserve_raw_member_target(
8150 identity_runtime.as_ref(),
8151 agent_identity,
8152 )
8153 .await
8154 {
8155 Ok(reservation) => reservation,
8156 Err(message) => return invalid_params(response_id, message),
8157 };
8158 let handle = runtime.handle();
8159 let spawn_result = Box::pin(handle.spawn_helper(
8160 crate::member_comms_id::mob_member_id(raw_reservation.alias()),
8161 task,
8162 options,
8163 ))
8164 .await;
8165 drop(raw_reservation);
8166 match spawn_result {
8167 Ok(result) => {
8168 response_value(
8173 response_id,
8174 Some(serde_json::json!({
8175 "output": result.output,
8176 "tokens_used": result.tokens_used,
8177 })),
8178 None,
8179 )
8180 }
8181 Err(err) => internal_error(response_id, format!("spawn_helper failed: {err}")),
8182 }
8183 }
8184 "mobkit/fork_helper" => {
8185 let Some(source) = request
8186 .params
8187 .get("source_member_id")
8188 .and_then(Value::as_str)
8189 else {
8190 return invalid_params(response_id, "source_member_id required");
8191 };
8192 let source = crate::member_comms_id::runtime_alias_str(source).into_owned();
8193 let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
8194 else {
8195 return invalid_params(response_id, "agent_identity required");
8196 };
8197 if let Err(message) = crate::member_comms_id::validate_raw_member_target(
8198 identity_runtime.as_ref(),
8199 agent_identity,
8200 )
8201 .await
8202 {
8203 return invalid_params(response_id, message);
8204 }
8205 let Some(task) = request.params.get("task").and_then(Value::as_str) else {
8206 return invalid_params(response_id, "task required");
8207 };
8208 let fork_context = match request.params.get("fork_context") {
8209 Some(v) if !v.is_null() => {
8210 match serde_json::from_value::<meerkat_mob::launch::ForkContext>(v.clone()) {
8211 Ok(ctx) => ctx,
8212 Err(err) => {
8213 return invalid_params(
8214 response_id,
8215 format!("invalid fork_context: {err}"),
8216 );
8217 }
8218 }
8219 }
8220 _ => meerkat_mob::launch::ForkContext::default(),
8221 };
8222 let options = match parse_console_helper_options(request.params.get("options")) {
8223 Ok(opts) => opts,
8224 Err(msg) => return invalid_params(response_id, msg),
8225 };
8226 let handle = runtime.handle();
8227 let source_member_id = crate::member_comms_id::mob_member_id(&source);
8228 let helper_alias = agent_identity.to_string();
8229 let task = task.to_string();
8230 let identity_runtime_owned = identity_runtime.clone();
8231 let authority_target = if let Some(identity_runtime_ref) = identity_runtime.as_ref() {
8232 identity_runtime_ref
8233 .member_alias_lifecycle_target(&source)
8234 .await
8235 } else {
8236 Ok(None)
8237 };
8238 let fork_result = match authority_target {
8239 Err(error) => Err(error.to_string()),
8240 Ok(Some(target)) => {
8241 crate::identity_first::IdentityRuntime::run_member_alias_targets_operation_tracked(
8242 vec![target],
8243 move || async move {
8244 let raw_reservation =
8245 crate::member_comms_id::reserve_raw_member_target(
8246 identity_runtime_owned.as_ref(),
8247 helper_alias.as_str(),
8248 )
8249 .await?;
8250 let helper_member_id = crate::member_comms_id::mob_member_id(
8251 raw_reservation.alias(),
8252 );
8253 let result = handle
8254 .fork_helper(
8255 &source_member_id,
8256 helper_member_id,
8257 task.as_str(),
8258 fork_context,
8259 options,
8260 )
8261 .await
8262 .map_err(|error| error.to_string());
8263 drop(raw_reservation);
8264 result
8265 },
8266 )
8267 .await
8268 .map_err(|error| error.to_string())
8269 }
8270 Ok(None) if crate::member_comms_id::is_reserved_generated_alias(&source) => {
8271 Err(format!(
8272 "generated source alias requires current identity authority: {source}"
8273 ))
8274 }
8275 Ok(None) => {
8276 match crate::member_comms_id::reserve_raw_member_target(
8277 identity_runtime_owned.as_ref(),
8278 helper_alias.as_str(),
8279 )
8280 .await
8281 {
8282 Err(error) => Err(error),
8283 Ok(raw_reservation) => {
8284 let helper_member_id = crate::member_comms_id::mob_member_id(
8285 raw_reservation.alias(),
8286 );
8287 let result = handle
8288 .fork_helper(
8289 &source_member_id,
8290 helper_member_id,
8291 task.as_str(),
8292 fork_context,
8293 options,
8294 )
8295 .await
8296 .map_err(|error| error.to_string());
8297 drop(raw_reservation);
8298 result
8299 }
8300 }
8301 }
8302 };
8303 match fork_result {
8304 Ok(result) => {
8305 response_value(
8309 response_id,
8310 Some(serde_json::json!({
8311 "output": result.output,
8312 "tokens_used": result.tokens_used,
8313 })),
8314 None,
8315 )
8316 }
8317 Err(err) => internal_error(response_id, format!("fork_helper failed: {err}")),
8318 }
8319 }
8320 "mobkit/attach_existing_session" => {
8321 let Some(role) = request.params.get("role").and_then(Value::as_str) else {
8322 return invalid_params(response_id, "role required");
8323 };
8324 let Some(agent_identity) = request.params.get("agent_identity").and_then(Value::as_str)
8325 else {
8326 return invalid_params(response_id, "agent_identity required");
8327 };
8328 let Some(session_id_str) = request.params.get("session_id").and_then(Value::as_str)
8329 else {
8330 return invalid_params(response_id, "session_id required");
8331 };
8332 let bridge_session_id = match meerkat_core::types::SessionId::parse(session_id_str) {
8333 Ok(s) => s,
8334 Err(_) => return invalid_params(response_id, "invalid session_id format"),
8335 };
8336 let raw_reservation = match crate::member_comms_id::reserve_raw_member_target(
8337 identity_runtime.as_ref(),
8338 agent_identity,
8339 )
8340 .await
8341 {
8342 Ok(reservation) => reservation,
8343 Err(message) => return invalid_params(response_id, message),
8344 };
8345 let mid = crate::member_comms_id::mob_member_id(raw_reservation.alias());
8346 let spec = SpawnMemberSpec::new(ProfileName::from(role), mid.clone())
8347 .with_launch_mode(MemberLaunchMode::Resume { bridge_session_id });
8348 let handle = runtime.handle();
8349 let spawn_result = Box::pin(handle.spawn_spec(spec)).await;
8350 drop(raw_reservation);
8351 match spawn_result {
8352 Ok(_) => match handle.member_status(&mid).await {
8353 Ok(snapshot) => response_value(
8354 response_id,
8355 Some(serde_json::to_value(&snapshot).unwrap_or(Value::Null)),
8356 None,
8357 ),
8358 Err(err) => internal_error(
8359 response_id,
8360 format!("attach_existing_session status lookup failed: {err}"),
8361 ),
8362 },
8363 Err(err) => internal_error(
8364 response_id,
8365 format!("attach_existing_session failed: {err}"),
8366 ),
8367 }
8368 }
8369 "mobkit/cross_mob/wire_local" => {
8370 handle_console_wire_local(
8371 runtime,
8372 identity_runtime.as_ref(),
8373 &request.params,
8374 response_id,
8375 true,
8376 )
8377 .await
8378 }
8379 "mobkit/cross_mob/unwire_local" => {
8380 handle_console_wire_local(
8381 runtime,
8382 identity_runtime.as_ref(),
8383 &request.params,
8384 response_id,
8385 false,
8386 )
8387 .await
8388 }
8389 "mobkit/peer_pubkey" => match gateway_peer_keys {
8390 Some(keys) => response_value(
8391 response_id,
8392 Some(serde_json::json!({ "pubkey_b64": keys.pubkey_b64() })),
8393 None,
8394 ),
8395 None => response_value(
8396 response_id,
8397 None,
8398 Some(JsonRpcError {
8399 code: -32004,
8400 message: "gateway has no signing keypair configured".to_string(),
8401 data: None,
8402 }),
8403 ),
8404 },
8405 "mobkit/cross_mob/peer_info" => {
8406 let member_id = request.params.get("member_id").and_then(Value::as_str);
8407 match member_id {
8408 Some(mid) if !mid.is_empty() => {
8409 let mid = crate::member_comms_id::runtime_alias_str(mid).into_owned();
8410 let handle = runtime.handle();
8411 let authority_target = if let Some(identity_runtime) = identity_runtime.as_ref()
8412 {
8413 identity_runtime.member_alias_lifecycle_target(&mid).await
8414 } else {
8415 Ok(None)
8416 };
8417 let result = match authority_target {
8418 Err(error) => Err(error.to_string()),
8419 Ok(Some(target)) => {
8420 let identity_runtime = identity_runtime.clone();
8421 crate::identity_first::IdentityRuntime::run_member_alias_targets_operation_tracked(
8422 vec![target],
8423 move || async move {
8424 console_member_peer_info(
8425 handle,
8426 identity_runtime.as_ref(),
8427 true,
8428 mid,
8429 )
8430 .await
8431 },
8432 )
8433 .await
8434 .map_err(|error| error.to_string())
8435 }
8436 Ok(None) if crate::member_comms_id::is_reserved_generated_alias(&mid) => {
8437 Err(format!(
8438 "generated member alias requires current identity authority: {mid}"
8439 ))
8440 }
8441 Ok(None) => console_member_peer_info(handle, None, false, mid).await,
8442 };
8443 match result {
8444 Ok(value) => response_value(response_id, Some(value), None),
8445 Err(error) => internal_error(
8446 response_id,
8447 format!("cross_mob/peer_info failed: {error}"),
8448 ),
8449 }
8450 }
8451 _ => invalid_params(response_id, "member_id required".to_string()),
8452 }
8453 }
8454 "mobkit/cross_mob/directory" => {
8455 let entries: Vec<Value> = contact_directory
8456 .map(|dir| {
8457 dir.list()
8458 .into_iter()
8459 .filter_map(|e| serde_json::to_value(e).ok())
8460 .collect()
8461 })
8462 .unwrap_or_default();
8463 response_value(
8464 response_id,
8465 Some(serde_json::json!({ "mobs": entries })),
8466 None,
8467 )
8468 }
8469 method
8470 if matches!(
8471 method,
8472 "mobkit/mob_labels/set"
8473 | "mobkit/mob_labels/get"
8474 | "mobkit/mob_labels/delete"
8475 | "mobkit/run_labels/set"
8476 | "mobkit/run_labels/get"
8477 | "mobkit/run_labels/delete",
8478 ) =>
8479 {
8480 dispatch_label_method(
8481 method,
8482 metadata_table.as_deref(),
8483 runtime.handle().mob_id().as_str(),
8484 response_id,
8485 &request.params,
8486 )
8487 .await
8488 }
8489 method if crate::rpc::workgraph_methods::is_workgraph_method(method) => {
8490 let admission = runtime.workgraph_admission();
8499 match crate::rpc::workgraph_methods::handle_workgraph_method(
8500 workgraph,
8501 &admission,
8502 crate::rpc::workgraph_methods::WorkgraphSurface::Console {
8503 authenticated_principal,
8504 },
8505 method,
8506 &request.params,
8507 )
8508 .await
8509 {
8510 Ok(result) => response_value(response_id, Some(result), None),
8511 Err(error) => response_value(response_id, None, Some(error)),
8512 }
8513 }
8514 _ => response_value(
8515 response_id,
8516 None,
8517 Some(JsonRpcError {
8518 code: -32601,
8519 message: "Method not found".to_string(),
8520 data: None,
8521 }),
8522 ),
8523 }
8524}
8525
8526async fn reconcile_console_topology(
8527 runtime: &MobRuntime,
8528 topology: Option<&crate::topology_control::TopologyRuntimeHandle>,
8529) -> crate::unified_runtime::UnifiedRuntimeReconcileEdgesReport {
8530 match topology {
8531 Some(topology) => topology.reconcile().await,
8532 None => crate::unified_runtime::edge_reconcile::reconcile_definition_edges(runtime).await,
8533 }
8534}
8535
8536async fn dispatch_label_method(
8543 method: &str,
8544 metadata_table: Option<&RuntimeMetadataTable>,
8545 mob_id: &str,
8546 response_id: Value,
8547 params: &Value,
8548) -> Value {
8549 let Some(table) = metadata_table else {
8550 return invalid_params(
8551 response_id,
8552 "metadata table not configured for this runtime",
8553 );
8554 };
8555
8556 let scope = match method {
8557 "mobkit/mob_labels/set" | "mobkit/mob_labels/get" | "mobkit/mob_labels/delete" => {
8558 MetadataScope::Mob(mob_id.to_string())
8559 }
8560 _ => match crate::runtime::parse_run_id_param(params) {
8561 Ok(run_id) => MetadataScope::Run(mob_id.to_string(), run_id.to_string()),
8562 Err(message) => return invalid_params(response_id, message),
8563 },
8564 };
8565
8566 let outcome = match method {
8567 "mobkit/mob_labels/set" | "mobkit/run_labels/set" => {
8568 crate::runtime::dispatch_labels_set(table, scope, params).await
8569 }
8570 "mobkit/mob_labels/get" | "mobkit/run_labels/get" => {
8571 crate::runtime::dispatch_labels_get(table, scope).await
8572 }
8573 "mobkit/mob_labels/delete" | "mobkit/run_labels/delete" => {
8574 crate::runtime::dispatch_labels_delete(table, scope).await
8575 }
8576 _ => unreachable!("dispatch_label_method called with non-label method: {method}"),
8577 };
8578
8579 match outcome {
8580 crate::runtime::LabelRpcResult::Accepted => response_value(
8581 response_id,
8582 Some(serde_json::json!({"accepted": true})),
8583 None,
8584 ),
8585 crate::runtime::LabelRpcResult::Labels(labels) => response_value(
8586 response_id,
8587 Some(serde_json::json!({"labels": labels_to_json_value(&labels)})),
8588 None,
8589 ),
8590 crate::runtime::LabelRpcResult::InvalidParams(message) => {
8591 invalid_params(response_id, message)
8592 }
8593 }
8594}
8595
8596async fn console_member_peer_info(
8597 handle: meerkat_mob::MobHandle,
8598 identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
8599 identity_authoritative: bool,
8600 member_alias: String,
8601) -> Result<Value, String> {
8602 let current_alias = if identity_authoritative {
8603 let identity_runtime = identity_runtime
8604 .ok_or_else(|| "durable peer target lost its IdentityRuntime authority".to_string())?;
8605 let identity = crate::identity_first::IdentityRuntime::identity_for_generated_member_alias(
8606 &member_alias,
8607 )
8608 .or_else(|| crate::identity_first::AgentIdentity::parse(&member_alias).ok())
8609 .ok_or_else(|| format!("invalid durable member alias {member_alias:?}"))?;
8610 identity_runtime
8611 .status(&identity)
8612 .await
8613 .map_err(|error| error.to_string())?
8614 .agent_runtime_id
8615 .map(|runtime_id| runtime_id.to_string())
8616 .ok_or_else(|| format!("identity {identity} has no current runtime member"))?
8617 } else {
8618 let direct = crate::member_comms_id::mob_member_id(&member_alias);
8619 if handle
8620 .get_member(&direct)
8621 .await
8622 .map_err(|error| error.to_string())?
8623 .is_some()
8624 {
8625 member_alias.clone()
8626 } else {
8627 let candidates = handle
8628 .list_members_including_retiring()
8629 .await
8630 .into_iter()
8631 .filter(|entry| {
8632 crate::member_comms_id::durable_identity_label(&entry.labels)
8633 .is_some_and(|identity| identity == member_alias)
8634 })
8635 .map(|entry| {
8636 crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str())
8637 .into_owned()
8638 })
8639 .collect::<BTreeSet<_>>();
8640 match candidates.len() {
8641 0 => member_alias.clone(),
8642 1 => candidates
8643 .into_iter()
8644 .next()
8645 .ok_or_else(|| "peer-info member alias candidate disappeared".to_string())?,
8646 _ => {
8647 return Err(format!(
8648 "ambiguous durable member alias {member_alias}: candidates [{}]",
8649 candidates.into_iter().collect::<Vec<_>>().join(", ")
8650 ));
8651 }
8652 }
8653 }
8654 };
8655 let member_id = crate::member_comms_id::mob_member_id(¤t_alias);
8656 let entry = handle
8657 .get_member(&member_id)
8658 .await
8659 .map_err(|error| format!("member lookup failed: {error}"))?
8660 .ok_or_else(|| format!("member {member_alias:?} not found"))?;
8661 let peer_id = entry
8662 .peer_id()
8663 .ok_or_else(|| format!("member {member_alias:?} has no comms runtime"))?;
8664 let mob_id = handle.mob_id().to_string();
8665 let comms_name = meerkat_core::MemberCommsName::new(
8666 &mob_id,
8667 entry.role.as_str(),
8668 entry.agent_identity.as_str(),
8669 )
8670 .map_err(|error| format!("invalid member comms name: {error}"))?
8671 .to_string();
8672 let address = format!("inproc://{comms_name}");
8673 Ok(serde_json::json!({
8674 "member_id": member_alias,
8675 "mob_id": mob_id,
8676 "comms_name": comms_name,
8677 "peer_id": peer_id,
8678 "address": address,
8679 }))
8680}
8681
8682async fn handle_console_wire_local(
8691 runtime: &MobRuntime,
8692 identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
8693 params: &Value,
8694 response_id: Value,
8695 wire: bool,
8696) -> Value {
8697 let local = params.get("local_member_id").and_then(Value::as_str);
8698 let comms_name = params.get("remote_comms_name").and_then(Value::as_str);
8699 let peer_id = params.get("remote_peer_id").and_then(Value::as_str);
8700 let addr = params.get("remote_address").and_then(Value::as_str);
8701
8702 let remote_pubkey = match params.get("remote_pubkey_b64") {
8703 None => None,
8704 Some(v) if v.is_null() => None,
8705 Some(v) => match v.as_str() {
8706 Some(s) if !s.is_empty() => match crate::auth::peer_keys::decode_pubkey_b64(s) {
8707 Ok(bytes) => Some(bytes),
8708 Err(err) => {
8709 return invalid_params(response_id, format!("remote_pubkey_b64: {err}"));
8710 }
8711 },
8712 _ => None,
8713 },
8714 };
8715
8716 let (local_id, cname, pid, address) = match (local, comms_name, peer_id, addr) {
8717 (Some(l), Some(c), Some(p), Some(a))
8718 if !l.is_empty() && !c.is_empty() && !p.is_empty() && !a.is_empty() =>
8719 {
8720 (l, c, p, a)
8721 }
8722 _ => {
8723 return invalid_params(
8724 response_id,
8725 "local_member_id, remote_comms_name, remote_peer_id, and remote_address required",
8726 );
8727 }
8728 };
8729
8730 let is_inproc = address.starts_with("inproc://");
8731 let spec_result = match (is_inproc, remote_pubkey) {
8732 (true, None) => TrustedPeerDescriptor::test_only_unsigned(cname, pid, address),
8733 (true, Some(bytes)) => {
8734 TrustedPeerDescriptor::unsigned_with_pubkey(cname, pid, bytes, address)
8735 }
8736 (false, None) => {
8737 return invalid_params(
8738 response_id,
8739 "remote_pubkey_b64 is required for non-inproc transports",
8740 );
8741 }
8742 (false, Some(bytes)) => {
8743 if bytes == [0u8; 32] {
8744 return invalid_params(
8745 response_id,
8746 "remote_pubkey_b64 must be non-zero for non-inproc transports",
8747 );
8748 }
8749 TrustedPeerDescriptor::unsigned_with_pubkey(cname, pid, bytes, address)
8750 }
8751 };
8752
8753 let spec = match spec_result {
8754 Ok(spec) => spec,
8755 Err(err) => {
8756 return invalid_params(response_id, format!("invalid peer spec: {err}"));
8757 }
8758 };
8759
8760 let handle = runtime.handle();
8761 let local_alias = crate::member_comms_id::runtime_alias_str(local_id).into_owned();
8762 let authority_target = if let Some(identity_runtime) = identity_runtime {
8763 identity_runtime
8764 .member_alias_lifecycle_target(&local_alias)
8765 .await
8766 } else {
8767 Ok(None)
8768 };
8769 let result = match authority_target {
8770 Err(error) => Err(error.to_string()),
8771 Ok(Some(target)) => {
8772 let operation_alias = local_alias.clone();
8773 crate::identity_first::IdentityRuntime::run_member_alias_targets_operation_tracked(
8774 vec![target],
8775 move || async move {
8776 let result = if wire {
8777 handle
8778 .wire(
8779 crate::member_comms_id::mob_member_id(&operation_alias),
8780 PeerTarget::External(spec),
8781 )
8782 .await
8783 } else {
8784 handle
8785 .unwire(
8786 crate::member_comms_id::mob_member_id(&operation_alias),
8787 PeerTarget::External(spec),
8788 )
8789 .await
8790 };
8791 result.map_err(|err| err.to_string())
8792 },
8793 )
8794 .await
8795 .map_err(|err| err.to_string())
8796 }
8797 Ok(None) if crate::member_comms_id::is_reserved_generated_alias(&local_alias) => Err(
8798 format!("generated member alias requires current identity authority: {local_alias}"),
8799 ),
8800 Ok(None) => {
8801 let result = if wire {
8802 handle
8803 .wire(
8804 crate::member_comms_id::mob_member_id(&local_alias),
8805 PeerTarget::External(spec),
8806 )
8807 .await
8808 } else {
8809 handle
8810 .unwire(
8811 crate::member_comms_id::mob_member_id(&local_alias),
8812 PeerTarget::External(spec),
8813 )
8814 .await
8815 };
8816 result.map_err(|err| err.to_string())
8817 }
8818 };
8819
8820 let action = if wire { "wire_local" } else { "unwire_local" };
8821 match result {
8822 Ok(()) => response_value(
8823 response_id,
8824 Some(serde_json::json!({
8825 "accepted": true,
8826 "local_member_id": local_id,
8827 "remote_comms_name": cname,
8828 })),
8829 None,
8830 ),
8831 Err(err) => internal_error(response_id, format!("cross_mob/{action} failed: {err}")),
8832 }
8833}
8834
8835async fn build_live_snapshot(
8836 runtime: &MobRuntime,
8837 config_module_ids: &[String],
8838 console_events: Option<&ConsoleEventStore>,
8839 visibility_policy: &dyn ConsoleVisibilityPolicy,
8840 read_model: &ConsoleSnapshotReadModel,
8841) -> ConsoleLiveSnapshot {
8842 let read_model_state = Box::pin(read_model.snapshot(runtime)).await;
8843 let running = read_model_state.running.unwrap_or(true);
8844 let mut members = read_model_state.primary_members.clone();
8851 if visibility_policy.include_implicit_delegate_members() {
8852 for group in &read_model_state.delegate_member_groups {
8853 members.extend(group.iter().cloned());
8854 }
8855 }
8856 dedupe_console_members_by_identity(&mut members);
8857 members.retain(|member| {
8858 visibility_policy.member_visible(member)
8859 && visibility_policy
8860 .identity_visible(&console_identity_record_from_console_member(member))
8861 });
8862
8863 let loaded_modules = if config_module_ids.is_empty() {
8867 let mut mods: Vec<String> = members
8868 .iter()
8869 .filter(|member| member.state != MEMBER_STATE_RETIRING)
8870 .map(|member| member.agent_identity.clone())
8871 .collect();
8872 mods.sort();
8873 mods
8874 } else {
8875 let mut mods = config_module_ids.to_vec();
8876 mods.sort();
8877 mods
8878 };
8879
8880 let agents = members
8881 .iter()
8882 .map(|member| async move {
8883 let console_identity = console_member_console_identity(member);
8884 let label = member
8885 .labels
8886 .get("display_name")
8887 .cloned()
8888 .unwrap_or_else(|| member.agent_identity.clone());
8889 let watched = member
8890 .labels
8891 .get("console_watched")
8892 .map(|value: &String| value == "true");
8893 let alert_level = member
8894 .labels
8895 .get("console_alert_level")
8896 .filter(|value: &&String| matches!(value.as_str(), "elevated" | "critical"))
8897 .cloned();
8898 let degraded = member
8899 .labels
8900 .get("console_degraded")
8901 .map(|value: &String| value == "true");
8902 let degraded_reason = member.labels.get("console_degraded_reason").cloned();
8903 let response_phase = match console_events {
8904 Some(store) => store.response_phase_for_identity(console_identity).await,
8905 None => None,
8906 };
8907 ConsoleAgentLiveSnapshot {
8908 agent_id: member.agent_identity.clone(),
8909 member_id: member.agent_identity.clone(),
8910 label,
8911 kind: "meerkat".to_string(),
8912 identity: Some(console_identity.to_string()),
8913 role: Some(member.role.clone()),
8914 state: Some(member.state.clone()),
8915 session_id: member.session_id.clone(),
8916 model_capabilities: member.model_capabilities.clone(),
8917 response_phase,
8918 watched,
8919 alert_level,
8920 degraded,
8921 degraded_reason,
8922 }
8923 })
8924 .collect::<Vec<_>>();
8925 let mut agents = join_all(agents).await;
8926 agents.sort_by(|left, right| left.label.cmp(&right.label));
8927 ConsoleLiveSnapshot::new(
8928 Some(runtime.handle().mob_id().to_string()),
8929 running,
8930 loaded_modules,
8931 agents,
8932 members,
8933 true,
8934 )
8935}
8936
8937async fn collect_console_snapshot_read_model(
8938 runtime: &MobRuntime,
8939) -> ConsoleSnapshotReadModelState {
8940 let handle = runtime.handle();
8941 let mut state = ConsoleSnapshotReadModelState {
8942 running: Some(matches!(
8943 handle.status_observation_snapshot(),
8944 MobState::Creating | MobState::Running
8945 )),
8946 ..ConsoleSnapshotReadModelState::default()
8947 };
8948 collect_console_session_index_for_handle(&handle, &mut state).await;
8949 let registered_labels = runtime.console_identity_labels().await;
8950
8951 let (primary_members, _primary_owner_index) =
8957 project_console_members_from_handle(&handle, None, None, &state, ®istered_labels).await;
8958 state.primary_members = primary_members;
8959
8960 let Some(mcp_state) = runtime.agent_mob_mcp_state() else {
8961 return state;
8962 };
8963 let primary_mob_id = handle.mob_id().to_string();
8964 let mut processed = BTreeSet::from([primary_mob_id]);
8965 let mut delegate_groups: Vec<Vec<ConsoleMember>> = Vec::new();
8966 loop {
8967 let mut progressed = false;
8968 for (mob_id, delegate_handle) in Box::pin(mcp_state.mob_handles_snapshot())
8969 .await
8970 .unwrap_or_default()
8971 {
8972 if processed.contains(mob_id.as_str()) {
8973 continue;
8974 }
8975 let Some(owner_authority) = delegate_handle.owner_bridge_session_lifecycle_authority()
8978 else {
8979 processed.insert(mob_id.to_string());
8980 continue;
8981 };
8982 let owner_session_id = owner_authority.bridge_session_id.to_string();
8983 let Some(host_identity) = state
8984 .session_owner_by_id
8985 .get(owner_session_id.as_str())
8986 .cloned()
8987 else {
8988 continue;
8989 };
8990 collect_console_session_index_for_handle(&delegate_handle, &mut state).await;
8991 let (delegate_members, _delegate_owner_index) = project_console_members_from_handle(
8992 &delegate_handle,
8993 Some(&host_identity),
8994 Some(mob_id.as_str()),
8995 &state,
8996 ®istered_labels,
8997 )
8998 .await;
8999 delegate_groups.push(delegate_members);
9000 processed.insert(mob_id.to_string());
9001 progressed = true;
9002 }
9003 if !progressed {
9004 break;
9005 }
9006 }
9007 state.delegate_member_groups = delegate_groups;
9008 state
9009}
9010
9011async fn collect_console_session_index_for_handle(
9012 handle: &MobHandle,
9013 state: &mut ConsoleSnapshotReadModelState,
9014) {
9015 for entry in handle.list_members_observation_snapshot().await {
9016 let identity =
9019 crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
9020 let Some(session_id) = handle
9021 .resolve_bridge_session_id_observation(&entry.agent_identity)
9022 .await
9023 .map(|session_id| session_id.to_string())
9024 else {
9025 state.session_id_by_identity.remove(&identity);
9026 continue;
9027 };
9028 state
9029 .session_owner_by_id
9030 .insert(session_id.clone(), identity.clone());
9031 state.session_id_by_identity.insert(identity, session_id);
9032 }
9033}
9034
9035fn apply_console_visibility_policy(
9036 snapshot: &mut ConsoleLiveSnapshot,
9037 visibility_policy: &dyn ConsoleVisibilityPolicy,
9038) {
9039 let mut hidden = BTreeSet::new();
9040 snapshot.members.retain(|member| {
9041 let visible = visibility_policy.member_visible(member)
9042 && visibility_policy
9043 .identity_visible(&console_identity_record_from_console_member(member));
9044 if !visible {
9045 hidden.insert(member.agent_identity.clone());
9046 }
9047 visible
9048 });
9049 snapshot
9050 .agents
9051 .retain(|agent| !hidden.contains(&agent.agent_id));
9052 snapshot
9053 .loaded_modules
9054 .retain(|module_id| !hidden.contains(module_id));
9055}
9056
9057async fn reset_all_live_console_agents(
9058 runtime: &MobRuntime,
9059 console_events: Option<&ConsoleEventStore>,
9060 console_aggregator: Option<&MobKitConsoleAggregator>,
9061 identity_runtime: Option<&Arc<crate::identity_first::IdentityRuntime>>,
9062 visibility_policy: &dyn ConsoleVisibilityPolicy,
9063) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
9064 let read_model = ConsoleSnapshotReadModel::default();
9065 *read_model.inner.write().await = Box::pin(collect_console_snapshot_read_model(runtime)).await;
9066 read_model
9069 .primed
9070 .store(true, std::sync::atomic::Ordering::Release);
9071 let snapshot = Box::pin(build_live_snapshot(
9072 runtime,
9073 &[],
9074 console_events,
9075 visibility_policy,
9076 &read_model,
9077 ))
9078 .await;
9079 let raw_snapshot = Box::pin(build_live_snapshot(
9080 runtime,
9081 &[],
9082 console_events,
9083 &crate::console_aggregator::AllowAllConsoleVisibilityPolicy,
9084 &read_model,
9085 ))
9086 .await;
9087 let identity_runtime_statuses = if let Some(identity_runtime) = identity_runtime {
9088 identity_runtime.statuses().await
9089 } else {
9090 Vec::new()
9091 };
9092 let identity_by_runtime_member_id = identity_runtime_statuses
9093 .iter()
9094 .filter_map(|status| {
9095 status
9096 .agent_runtime_id
9097 .as_ref()
9098 .map(|runtime_id| (runtime_id.as_str().to_string(), status.identity.to_string()))
9099 })
9100 .collect::<BTreeMap<_, _>>();
9101 let mut durable_identity_runtime_identities = identity_runtime_statuses
9102 .iter()
9103 .filter(|status| identity_status_visible_to_console(visibility_policy, status))
9104 .map(|status| status.identity.to_string())
9105 .collect::<BTreeSet<_>>();
9106 let mut main_identities = BTreeSet::new();
9107 let mut runtime_member_id_by_identity = BTreeMap::new();
9108 let mut runtime_member_ids_by_identity: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
9109 let mut session_id_by_identity_runtime_member: BTreeMap<(String, String), Option<String>> =
9110 BTreeMap::new();
9111 let mut live_alias_by_runtime_member_id: BTreeMap<String, (String, Option<String>)> =
9112 BTreeMap::new();
9113 let mut visible_runtime_member_ids = BTreeSet::new();
9114 let mut duplicate_live_identities = BTreeSet::new();
9115 let mut delegate_members = BTreeSet::new();
9116 for member in snapshot.members {
9117 if member.state == MEMBER_STATE_RETIRING {
9118 continue;
9119 }
9120 if let Some(source_mob_id) = member.labels.get("source_mob_id").cloned() {
9121 delegate_members.insert((source_mob_id, member.agent_identity));
9122 } else {
9123 let identity = crate::member_comms_id::durable_identity_label(&member.labels)
9124 .map(str::to_owned)
9125 .or_else(|| {
9126 identity_by_runtime_member_id
9127 .get(&member.agent_identity)
9128 .cloned()
9129 })
9130 .unwrap_or_else(|| member.agent_identity.clone());
9131 if let Some(existing) = runtime_member_id_by_identity.get(&identity)
9132 && existing != &member.agent_identity
9133 {
9134 duplicate_live_identities.insert(identity.clone());
9135 }
9136 runtime_member_ids_by_identity
9137 .entry(identity.clone())
9138 .or_default()
9139 .insert(member.agent_identity.clone());
9140 session_id_by_identity_runtime_member.insert(
9141 (identity.clone(), member.agent_identity.clone()),
9142 member.session_id.clone(),
9143 );
9144 live_alias_by_runtime_member_id.insert(
9145 member.agent_identity.clone(),
9146 (identity.clone(), member.session_id.clone()),
9147 );
9148 visible_runtime_member_ids.insert(member.agent_identity.clone());
9149 runtime_member_id_by_identity
9150 .entry(identity.clone())
9151 .or_insert(member.agent_identity);
9152 main_identities.insert(identity);
9153 }
9154 }
9155 let mut raw_runtime_member_ids_by_identity: BTreeMap<String, BTreeSet<String>> =
9156 BTreeMap::new();
9157 let mut raw_session_id_by_identity_runtime_member: BTreeMap<(String, String), Option<String>> =
9158 BTreeMap::new();
9159 let mut raw_live_alias_by_runtime_member_id: BTreeMap<String, (String, Option<String>)> =
9160 BTreeMap::new();
9161 for member in raw_snapshot.members {
9162 if member.state == MEMBER_STATE_RETIRING || member.labels.contains_key("source_mob_id") {
9163 continue;
9164 }
9165 let identity = crate::member_comms_id::durable_identity_label(&member.labels)
9166 .map(str::to_owned)
9167 .or_else(|| {
9168 identity_by_runtime_member_id
9169 .get(&member.agent_identity)
9170 .cloned()
9171 })
9172 .unwrap_or_else(|| member.agent_identity.clone());
9173 raw_runtime_member_ids_by_identity
9174 .entry(identity.clone())
9175 .or_default()
9176 .insert(member.agent_identity.clone());
9177 raw_session_id_by_identity_runtime_member.insert(
9178 (identity.clone(), member.agent_identity.clone()),
9179 member.session_id.clone(),
9180 );
9181 raw_live_alias_by_runtime_member_id
9182 .insert(member.agent_identity, (identity, member.session_id));
9183 }
9184 durable_identity_runtime_identities.retain(|identity| {
9185 identity_runtime_statuses
9186 .iter()
9187 .find(|status| status.identity.as_str() == identity)
9188 .and_then(|status| status.agent_runtime_id.as_ref())
9189 .is_none_or(|runtime_id| {
9190 let runtime_id = runtime_id.as_str();
9191 !raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
9192 || visible_runtime_member_ids.contains(runtime_id)
9193 })
9194 });
9195 let current_main_identities = main_identities.clone();
9196 let baseline_specs = runtime.baseline_member_specs().await;
9197 let baseline_identities = baseline_specs
9198 .iter()
9199 .filter(|spec| baseline_spec_visible_to_console(visibility_policy, spec))
9200 .map(|spec| spec.identity.to_string())
9201 .collect::<BTreeSet<_>>();
9202 main_identities.extend(baseline_identities.iter().cloned());
9203 main_identities.extend(durable_identity_runtime_identities.iter().cloned());
9204
9205 let mut retired_delegates = Vec::new();
9206 let mut reset_main = Vec::new();
9207 let mut retired_delegate_details = Vec::new();
9208 let mut reset_details = Vec::new();
9209 let mut failures = Vec::new();
9210 let mut warnings = Vec::new();
9211
9212 for identity in &main_identities {
9213 let parsed_identity = crate::identity_first::AgentIdentity::parse(identity).ok();
9214 let registered_status = if let (Some(identity_runtime), Some(parsed_identity)) =
9215 (identity_runtime, parsed_identity.as_ref())
9216 {
9217 identity_runtime.status(parsed_identity).await.ok()
9218 } else {
9219 None
9220 };
9221 let baseline_identity_runtime_registered = registered_status.is_some();
9222 if baseline_identities.contains(identity)
9223 && !current_main_identities.contains(identity)
9224 && !baseline_identity_runtime_registered
9225 {
9226 continue;
9227 }
9228 let registered_runtime_id = registered_status
9229 .as_ref()
9230 .and_then(|status| status.agent_runtime_id.as_ref())
9231 .map(crate::identity_first::AgentRuntimeId::as_str);
9232 let registered_visible = registered_runtime_id
9233 .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
9234 let registered_hidden = registered_runtime_id.is_some_and(|runtime_id| {
9235 raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
9236 && !visible_runtime_member_ids.contains(runtime_id)
9237 });
9238 if registered_hidden {
9239 continue;
9240 }
9241 if duplicate_live_identities.contains(identity) && !registered_visible {
9242 failures.push(json!({
9243 "identity": identity,
9244 "error": "ambiguous live identity alias",
9245 }));
9246 continue;
9247 }
9248 if let Some(status) = registered_status.as_ref() {
9249 if let Some(registered_runtime_id) = registered_runtime_id
9250 && let Some((live_identity, _live_session_id)) =
9251 raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
9252 && live_identity != identity
9253 {
9254 failures.push(json!({
9255 "identity": identity,
9256 "error": format!(
9257 "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
9258 ),
9259 "kind": "stale_live_identity_alias",
9260 }));
9261 continue;
9262 }
9263 if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(identity) {
9264 if !registered_runtime_id
9265 .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
9266 {
9267 failures.push(json!({
9268 "identity": identity,
9269 "error": format!(
9270 "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
9271 registered_runtime_id.unwrap_or("<none>"),
9272 live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
9273 ),
9274 "kind": "stale_live_identity_alias",
9275 }));
9276 continue;
9277 }
9278 if let Some(registered_runtime_id) = registered_runtime_id
9279 && let Some(registered_session_id) =
9280 status.session_id.as_ref().map(ToString::to_string)
9281 && let Some(Some(live_session_id)) = raw_session_id_by_identity_runtime_member
9282 .get(&(identity.clone(), registered_runtime_id.to_string()))
9283 && live_session_id != ®istered_session_id
9284 {
9285 failures.push(json!({
9286 "identity": identity,
9287 "error": format!(
9288 "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}"
9289 ),
9290 "kind": "stale_live_identity_alias",
9291 }));
9292 continue;
9293 }
9294 }
9295 if baseline_identities.contains(identity)
9296 && !identity_runtime
9297 .is_some_and(|identity_runtime| identity_runtime.has_session_bridge())
9298 {
9299 failures.push(json!({
9300 "identity": identity,
9301 "error": "reset requires an identity runtime with a session bridge",
9302 "kind": "identity_reset_requires_session_bridge",
9303 }));
9304 }
9305 continue;
9306 }
9307
9308 let runtime_member_id = runtime_member_id_by_identity
9309 .get(identity)
9310 .map(String::as_str)
9311 .unwrap_or(identity.as_str());
9312 if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
9313 && bound_identity != identity
9314 {
9315 failures.push(json!({
9316 "identity": identity,
9317 "error": format!(
9318 "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
9319 ),
9320 "kind": "stale_live_identity_alias",
9321 }));
9322 }
9323 }
9324
9325 if !failures.is_empty() {
9326 return Ok(json!({
9327 "reset": reset_main,
9328 "retired_delegates": retired_delegates,
9329 "reset_details": reset_details,
9330 "retired_delegate_details": retired_delegate_details,
9331 "warnings": warnings,
9332 "failed": failures,
9333 "startup_history": Value::Null,
9334 }));
9335 }
9336
9337 if let Some(state) = runtime.agent_mob_mcp_state() {
9338 for (mob_id, identity) in delegate_members {
9339 match Box::pin(state.handle_for(&MobId::from(mob_id.as_str()))).await {
9340 Ok(handle) => {
9341 match retire_console_member(
9342 &handle,
9343 &crate::member_comms_id::mob_member_id(identity.as_str()),
9344 )
9345 .await
9346 {
9347 Ok(()) => {
9348 let detail = json!({
9349 "identity": identity,
9350 "mob_id": mob_id,
9351 });
9352 retired_delegates.push(detail.clone());
9353 retired_delegate_details.push(detail);
9354 }
9355 Err(err) => failures.push(json!({
9356 "identity": identity,
9357 "mob_id": mob_id,
9358 "error": err,
9359 })),
9360 }
9361 }
9362 Err(err) => failures.push(json!({
9363 "identity": identity,
9364 "mob_id": mob_id,
9365 "error": err.to_string(),
9366 })),
9367 }
9368 }
9369 } else if let Some(aggregator) = console_aggregator {
9370 let identities = delegate_members
9371 .into_iter()
9372 .map(|(_, identity)| identity)
9373 .collect::<BTreeSet<_>>();
9374 for identity in identities {
9375 match Box::pin(aggregator.retire_identity(&identity)).await {
9376 Ok(true) => {
9377 let detail = json!({ "identity": identity });
9378 retired_delegates.push(detail.clone());
9379 retired_delegate_details.push(detail);
9380 }
9381 Ok(false) => failures.push(json!({
9382 "identity": identity,
9383 "error": "unknown identity",
9384 })),
9385 Err(err) => failures.push(json!({
9386 "identity": identity,
9387 "error": err.to_string(),
9388 })),
9389 }
9390 }
9391 }
9392
9393 let handle = runtime.handle();
9394 for spec in baseline_specs {
9395 let identity = spec.identity.to_string();
9396 if !baseline_spec_visible_to_console(visibility_policy, &spec) {
9397 continue;
9398 }
9399 if current_main_identities.contains(&identity) {
9400 continue;
9401 }
9402 if let Some(identity_runtime) = identity_runtime
9403 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
9404 && identity_runtime.status(&parsed_identity).await.is_ok()
9405 {
9406 continue;
9407 }
9408 match handle.ensure_member(spec).await {
9409 Ok(_outcome) => {
9410 if let Some(store) = console_events {
9411 store
9412 .record_lifecycle(
9413 &identity,
9414 "identity_reset",
9415 json!({ "scope": "reset_all", "restored": true }),
9416 )
9417 .await;
9418 }
9419 reset_main.push(identity.clone());
9420 reset_details.push(json!({ "identity": identity }));
9421 }
9422 Err(err) => failures.push(json!({
9423 "identity": identity,
9424 "error": err.to_string(),
9425 })),
9426 }
9427 }
9428 for identity in main_identities {
9429 let baseline_identity_runtime_registered = if let Some(identity_runtime) = identity_runtime
9430 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
9431 {
9432 identity_runtime.status(&parsed_identity).await.is_ok()
9433 } else {
9434 false
9435 };
9436 if baseline_identities.contains(&identity)
9437 && !current_main_identities.contains(&identity)
9438 && !baseline_identity_runtime_registered
9439 {
9440 continue;
9441 }
9442 let registered_status = if let Some(identity_runtime) = identity_runtime
9443 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
9444 {
9445 identity_runtime.status(&parsed_identity).await.ok()
9446 } else {
9447 None
9448 };
9449 let registered_runtime_id = registered_status
9450 .as_ref()
9451 .and_then(|status| status.agent_runtime_id.as_ref())
9452 .map(crate::identity_first::AgentRuntimeId::as_str);
9453 let registered_visible = registered_runtime_id
9454 .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
9455 let registered_hidden = registered_runtime_id.is_some_and(|runtime_id| {
9456 raw_live_alias_by_runtime_member_id.contains_key(runtime_id)
9457 && !visible_runtime_member_ids.contains(runtime_id)
9458 });
9459 if registered_hidden {
9460 continue;
9461 }
9462 if duplicate_live_identities.contains(&identity) && !registered_visible {
9463 failures.push(json!({
9464 "identity": identity,
9465 "error": "ambiguous live identity alias",
9466 }));
9467 continue;
9468 }
9469 if baseline_identities.contains(&identity) {
9470 if let Some(identity_runtime) = identity_runtime
9471 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
9472 && let Ok(status) = identity_runtime.status(&parsed_identity).await
9473 {
9474 let registered_runtime_id = status
9475 .agent_runtime_id
9476 .as_ref()
9477 .map(crate::identity_first::AgentRuntimeId::as_str);
9478 if let Some(registered_runtime_id) = registered_runtime_id
9479 && let Some((live_identity, _live_session_id)) =
9480 raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
9481 && live_identity != &identity
9482 {
9483 failures.push(json!({
9484 "identity": identity,
9485 "error": format!(
9486 "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
9487 ),
9488 "kind": "stale_live_identity_alias",
9489 }));
9490 continue;
9491 }
9492 if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(&identity) {
9493 if !registered_runtime_id
9494 .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
9495 {
9496 failures.push(json!({
9497 "identity": identity,
9498 "error": format!(
9499 "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
9500 registered_runtime_id.unwrap_or("<none>"),
9501 live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
9502 ),
9503 "kind": "stale_live_identity_alias",
9504 }));
9505 continue;
9506 }
9507 if let Some(registered_runtime_id) = registered_runtime_id
9508 && let Some(registered_session_id) =
9509 status.session_id.as_ref().map(ToString::to_string)
9510 && let Some(Some(live_session_id)) =
9511 raw_session_id_by_identity_runtime_member
9512 .get(&(identity.clone(), registered_runtime_id.to_string()))
9513 && live_session_id != ®istered_session_id
9514 {
9515 failures.push(json!({
9516 "identity": identity,
9517 "error": format!(
9518 "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}"
9519 ),
9520 "kind": "stale_live_identity_alias",
9521 }));
9522 continue;
9523 }
9524 }
9525 if !identity_runtime.has_session_bridge() {
9526 failures.push(json!({
9527 "identity": identity,
9528 "error": "reset requires an identity runtime with a session bridge",
9529 "kind": "identity_reset_requires_session_bridge",
9530 }));
9531 continue;
9532 }
9533 match identity_runtime.reset_tracked(&parsed_identity).await {
9534 Ok(record) => {
9535 let cleanup_warning = json!({
9536 "identity": identity,
9537 "kind": "stale_member_cleanup_skipped_after_identity_reset",
9538 "message": "reset published the new generation without retiring stale live mob members; identity control calls reject stale runtime ids",
9539 });
9540 warnings.push(cleanup_warning);
9541 reset_details.push(json!({
9542 "identity": identity,
9543 "cleanup_warning": warnings.last().cloned(),
9544 }));
9545 reset_main.push(identity);
9546 if let Some(store) = console_events {
9547 store
9548 .record_lifecycle(
9549 parsed_identity.as_str(),
9550 "identity_reset",
9551 json!({
9552 "scope": "reset_all",
9553 "generation": record.generation.get(),
9554 "checkpoint_version": record.checkpoint_version.get(),
9555 "cleanup_warning": warnings.last().cloned(),
9556 }),
9557 )
9558 .await;
9559 }
9560 }
9561 Err(err) => failures.push(json!({
9562 "identity": identity,
9563 "error": err.to_string(),
9564 })),
9565 }
9566 continue;
9567 }
9568 let runtime_member_id = runtime_member_id_by_identity
9569 .get(&identity)
9570 .map(String::as_str)
9571 .unwrap_or(identity.as_str());
9572 if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
9573 && bound_identity != &identity
9574 {
9575 failures.push(json!({
9576 "identity": identity,
9577 "error": format!(
9578 "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
9579 ),
9580 "kind": "stale_live_identity_alias",
9581 }));
9582 continue;
9583 }
9584 match Box::pin(respawn_console_member(
9585 &handle,
9586 &crate::member_comms_id::mob_member_id(runtime_member_id),
9587 ))
9588 .await
9589 {
9590 Ok(topology_restore_warning) => {
9591 if let Some(store) = console_events {
9592 store
9593 .record_lifecycle(
9594 &identity,
9595 "identity_reset",
9596 json!({
9597 "scope": "reset_all",
9598 "topology_restore_warning": topology_restore_warning.clone(),
9599 }),
9600 )
9601 .await;
9602 }
9603 reset_main.push(identity.clone());
9604 let mut detail = json!({ "identity": identity });
9605 if let Some(warning) = topology_restore_warning {
9606 detail["topology_restore_warning"] = warning;
9607 }
9608 reset_details.push(detail);
9609 }
9610 Err(err) => failures.push(json!({
9611 "identity": identity,
9612 "error": err,
9613 })),
9614 }
9615 } else {
9616 if let Some(identity_runtime) = identity_runtime
9617 && let Ok(parsed_identity) = crate::identity_first::AgentIdentity::parse(&identity)
9618 && let Ok(registered_status) = identity_runtime.status(&parsed_identity).await
9619 {
9620 let registered_runtime_id = registered_status
9621 .agent_runtime_id
9622 .as_ref()
9623 .map(crate::identity_first::AgentRuntimeId::as_str);
9624 let registered_visible = registered_runtime_id
9625 .is_some_and(|runtime_id| visible_runtime_member_ids.contains(runtime_id));
9626 if duplicate_live_identities.contains(&identity) && !registered_visible {
9627 failures.push(json!({
9628 "identity": identity,
9629 "error": "ambiguous live identity alias",
9630 }));
9631 continue;
9632 }
9633 if let Some(registered_runtime_id) = registered_runtime_id
9634 && let Some((live_identity, _live_session_id)) =
9635 raw_live_alias_by_runtime_member_id.get(registered_runtime_id)
9636 && live_identity != &identity
9637 {
9638 failures.push(json!({
9639 "identity": identity,
9640 "error": format!(
9641 "stale live identity alias: identity runtime binding points at {registered_runtime_id}, but live console alias projects identity {live_identity}"
9642 ),
9643 "kind": "stale_live_identity_alias",
9644 }));
9645 continue;
9646 }
9647 if let Some(live_runtime_ids) = raw_runtime_member_ids_by_identity.get(&identity) {
9648 if !registered_runtime_id
9649 .is_some_and(|runtime_id| live_runtime_ids.contains(runtime_id))
9650 {
9651 failures.push(json!({
9652 "identity": identity,
9653 "error": format!(
9654 "stale live identity alias: identity runtime binding points at {}, but live console alias resolves to [{}]",
9655 registered_runtime_id.unwrap_or("<none>"),
9656 live_runtime_ids.iter().cloned().collect::<Vec<_>>().join(", ")
9657 ),
9658 "kind": "stale_live_identity_alias",
9659 }));
9660 continue;
9661 }
9662 if let Some(registered_runtime_id) = registered_runtime_id
9663 && let Some(registered_session_id) = registered_status
9664 .session_id
9665 .as_ref()
9666 .map(ToString::to_string)
9667 && let Some(Some(live_session_id)) =
9668 raw_session_id_by_identity_runtime_member
9669 .get(&(identity.clone(), registered_runtime_id.to_string()))
9670 && live_session_id != ®istered_session_id
9671 {
9672 failures.push(json!({
9673 "identity": identity,
9674 "error": format!(
9675 "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}"
9676 ),
9677 "kind": "stale_live_identity_alias",
9678 }));
9679 continue;
9680 }
9681 }
9682 let stale_member_ids = stale_console_member_ids_for_identity(
9683 &handle,
9684 visibility_policy,
9685 parsed_identity.as_str(),
9686 registered_status
9687 .agent_runtime_id
9688 .as_ref()
9689 .map(crate::identity_first::AgentRuntimeId::as_str),
9690 !identity_runtime.has_session_bridge(),
9691 )
9692 .await;
9693 match identity_runtime.retire_tracked(&parsed_identity).await {
9694 Ok(token) => match retire_console_member_ids(&handle, stale_member_ids).await {
9695 Ok(()) => {
9696 retired_delegate_details.push(json!({ "identity": identity }));
9697 retired_delegates.push(json!({ "identity": identity }));
9698 if let Some(store) = console_events {
9699 store
9700 .record_lifecycle(
9701 parsed_identity.as_str(),
9702 "identity_retired",
9703 json!({
9704 "scope": "reset_all",
9705 "dynamic": true,
9706 "fencing_token": token.get(),
9707 }),
9708 )
9709 .await;
9710 }
9711 }
9712 Err(err) => {
9713 warnings.push(json!({
9714 "identity": identity,
9715 "kind": "stale_member_cleanup_failed_after_identity_retire",
9716 "message": err,
9717 }));
9718 retired_delegate_details.push(json!({
9719 "identity": identity,
9720 "cleanup_warning": warnings.last().cloned(),
9721 }));
9722 retired_delegates.push(json!({ "identity": identity }));
9723 if let Some(store) = console_events {
9724 store
9725 .record_lifecycle(
9726 parsed_identity.as_str(),
9727 "identity_retired",
9728 json!({
9729 "scope": "reset_all",
9730 "dynamic": true,
9731 "fencing_token": token.get(),
9732 "cleanup_warning": warnings.last().cloned(),
9733 }),
9734 )
9735 .await;
9736 }
9737 }
9738 },
9739 Err(err) => failures.push(json!({
9740 "identity": identity,
9741 "error": err.to_string(),
9742 })),
9743 }
9744 continue;
9745 }
9746 let runtime_member_id = runtime_member_id_by_identity
9747 .get(&identity)
9748 .map(String::as_str)
9749 .unwrap_or(identity.as_str());
9750 if let Some(bound_identity) = identity_by_runtime_member_id.get(runtime_member_id)
9751 && bound_identity != &identity
9752 {
9753 failures.push(json!({
9754 "identity": identity,
9755 "error": format!(
9756 "stale live identity alias: live console alias resolves to {runtime_member_id}, but identity runtime binding belongs to {bound_identity}"
9757 ),
9758 "kind": "stale_live_identity_alias",
9759 }));
9760 continue;
9761 }
9762 match retire_console_member(
9763 &handle,
9764 &crate::member_comms_id::mob_member_id(runtime_member_id),
9765 )
9766 .await
9767 {
9768 Ok(()) => {
9769 if let Some(store) = console_events {
9770 store
9771 .record_lifecycle(
9772 &identity,
9773 "identity_retired",
9774 json!({ "scope": "reset_all", "dynamic": true }),
9775 )
9776 .await;
9777 }
9778 retired_delegates.push(json!({ "identity": identity }));
9779 retired_delegate_details.push(json!({ "identity": identity }));
9780 }
9781 Err(err) => failures.push(json!({
9782 "identity": identity,
9783 "error": err,
9784 })),
9785 }
9786 }
9787 }
9788
9789 let startup_history = if failures.is_empty() {
9790 if let Some(aggregator) = console_aggregator {
9791 Box::pin(wait_for_reset_startup_history(
9792 aggregator,
9793 reset_main.iter().cloned().collect::<BTreeSet<_>>(),
9794 Duration::from_secs(10),
9795 ))
9796 .await
9797 .unwrap_or_else(|err| json!({ "error": err.to_string() }))
9798 } else {
9799 Value::Null
9800 }
9801 } else {
9802 Value::Null
9803 };
9804
9805 Ok(json!({
9806 "reset": reset_main,
9807 "retired_delegates": retired_delegates,
9808 "reset_details": reset_details,
9809 "retired_delegate_details": retired_delegate_details,
9810 "warnings": warnings,
9811 "failed": failures,
9812 "startup_history": startup_history,
9813 }))
9814}
9815
9816async fn wait_for_reset_startup_history(
9817 aggregator: &MobKitConsoleAggregator,
9818 identities: BTreeSet<String>,
9819 timeout: Duration,
9820) -> ConsoleLogResult<Value> {
9821 if identities.is_empty() {
9822 return Ok(json!({
9823 "timeout": false,
9824 "ready": Vec::<String>::new(),
9825 "pending": Vec::<String>::new(),
9826 }));
9827 }
9828
9829 let deadline = Instant::now() + timeout;
9830 let mut pending = identities;
9831 let mut ready = BTreeSet::new();
9832 while !pending.is_empty() {
9833 for identity in pending.clone() {
9834 let page = Box::pin(aggregator.query_timeline(ConsoleTimelineQuery {
9835 identity: Some(identity.clone()),
9836 limit: 1000,
9837 ..ConsoleTimelineQuery::default()
9838 }))
9839 .await?;
9840 let startup_completed = page.frames.iter().any(|frame| {
9841 matches!(
9842 frame.kind.as_str(),
9843 "interaction_complete" | "turn_completed"
9844 )
9845 });
9846 if startup_completed {
9847 pending.remove(&identity);
9848 ready.insert(identity);
9849 }
9850 }
9851
9852 if pending.is_empty() {
9853 break;
9854 }
9855 if Instant::now() >= deadline {
9856 return Ok(json!({
9857 "timeout": true,
9858 "ready": ready.into_iter().collect::<Vec<_>>(),
9859 "pending": pending.into_iter().collect::<Vec<_>>(),
9860 }));
9861 }
9862 tokio::time::sleep(Duration::from_millis(250)).await;
9863 }
9864
9865 Ok(json!({
9866 "timeout": false,
9867 "ready": ready.into_iter().collect::<Vec<_>>(),
9868 "pending": Vec::<String>::new(),
9869 }))
9870}
9871
9872fn dedupe_console_members_by_identity(members: &mut Vec<ConsoleMember>) {
9873 let mut seen_member_ids = BTreeSet::new();
9874 members.retain(|member| seen_member_ids.insert(member.agent_identity.clone()));
9875}
9876
9877fn console_member_console_identity(member: &ConsoleMember) -> &str {
9878 crate::member_comms_id::durable_identity_label(&member.labels)
9879 .unwrap_or(member.agent_identity.as_str())
9880}
9881
9882fn console_identity_record_from_console_member(member: &ConsoleMember) -> ConsoleIdentityRecord {
9883 let identity = console_member_console_identity(member).to_string();
9884 let addressable = member
9885 .labels
9886 .get("addressable")
9887 .map(|value| !value.eq_ignore_ascii_case("false"))
9888 .unwrap_or(true)
9889 && member.state == MEMBER_STATE_ACTIVE;
9890 let visibility = if member.state == MEMBER_STATE_RETIRING {
9891 ConsoleVisibility::RetiredReadable
9892 } else if addressable {
9893 ConsoleVisibility::Addressable
9894 } else {
9895 ConsoleVisibility::Hidden
9896 };
9897 ConsoleIdentityRecord {
9898 identity: identity.clone(),
9899 display_name: member
9900 .labels
9901 .get("display_name")
9902 .cloned()
9903 .unwrap_or(identity),
9904 runtime_key: "runtime".to_string(),
9905 runtime_member_id: member.agent_identity.clone(),
9906 session_id: member.session_id.clone(),
9907 visibility,
9908 addressable,
9909 health: member.state.clone(),
9910 topology_peers: member.wired_to.clone(),
9911 labels: member.labels.clone(),
9912 }
9913}
9914
9915fn baseline_spec_visible_to_console(
9916 visibility_policy: &dyn ConsoleVisibilityPolicy,
9917 spec: &SpawnMemberSpec,
9918) -> bool {
9919 let mut labels = spec.labels.clone().unwrap_or_default();
9920 labels
9921 .entry("role".to_string())
9922 .or_insert_with(|| spec.role_name.to_string());
9923 let record = ConsoleIdentityRecord {
9924 identity: spec.identity.to_string(),
9925 display_name: spec.identity.to_string(),
9926 runtime_key: "baseline".to_string(),
9927 runtime_member_id: spec.identity.to_string(),
9928 session_id: None,
9929 visibility: ConsoleVisibility::Addressable,
9930 addressable: true,
9931 health: "baseline".to_string(),
9932 topology_peers: Vec::new(),
9933 labels,
9934 };
9935 let member = ConsoleMember {
9936 agent_identity: spec.identity.to_string(),
9937 role: spec.role_name.to_string(),
9938 state: MEMBER_STATE_ACTIVE.to_string(),
9939 model_capabilities: ConsoleModelCapabilities::default(),
9940 runtime_mode: spec
9941 .runtime_mode
9942 .as_ref()
9943 .map(std::string::ToString::to_string),
9944 session_id: None,
9945 wired_to: Vec::new(),
9946 labels: record.labels.clone(),
9947 progress: None,
9948 };
9949 visibility_policy.member_visible(&member) && visibility_policy.identity_visible(&record)
9950}
9951
9952pub(crate) async fn prime_access_cache_from_runtime(
9966 runtime: &MobRuntime,
9967 access: &AccessController,
9968) {
9969 if !access.enabled() {
9970 return;
9971 }
9972 let registered = runtime.console_identity_labels().await;
9973 prime_access_cache_from_handle_with_registry(&runtime.handle(), access, ®istered).await;
9974}
9975
9976pub(crate) async fn prime_access_cache_from_handle(handle: &MobHandle, access: &AccessController) {
9977 if !access.enabled() {
9978 return;
9979 }
9980 prime_access_cache_from_handle_with_registry(handle, access, &BTreeMap::new()).await;
9981}
9982
9983async fn prime_access_cache_from_handle_with_registry(
9984 handle: &MobHandle,
9985 access: &AccessController,
9986 registered: &BTreeMap<String, BTreeMap<String, String>>,
9987) {
9988 let mut registry_only: BTreeMap<&String, &BTreeMap<String, String>> =
9989 registered.iter().collect();
9990 for entry in handle.list_all_members().await {
9991 let member_identity =
9994 crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
9995 let console_identity = crate::member_comms_id::durable_identity_label(&entry.labels)
9998 .map_or_else(|| member_identity.clone(), ToString::to_string);
9999 let mut labels = entry.labels.clone();
10000 crate::console_spawn::sanitize_unverified_lineage_labels(&mut labels);
10004 if let Some(spawn_labels) = registry_only.remove(&console_identity) {
10005 crate::console_spawn::merge_registered_labels(&mut labels, spawn_labels);
10006 }
10007 access.record_agent_attributes(AgentResourceAttributes {
10008 identity: console_identity,
10009 agent_id: Some(member_identity),
10010 role: Some(entry.role.to_string()),
10011 labels,
10012 });
10013 }
10014 for (identity, spawn_labels) in registry_only {
10015 access.record_agent_attributes(AgentResourceAttributes {
10016 identity: identity.clone(),
10017 agent_id: None,
10018 role: spawn_labels.get("role").cloned(),
10019 labels: spawn_labels.clone(),
10020 });
10021 }
10022}
10023
10024async fn project_console_members_from_handle(
10025 handle: &MobHandle,
10026 host_identity: Option<&str>,
10027 source_mob_id: Option<&str>,
10028 read_model: &ConsoleSnapshotReadModelState,
10029 registered_labels: &BTreeMap<String, BTreeMap<String, String>>,
10030) -> (Vec<ConsoleMember>, BTreeMap<String, String>) {
10031 let entries = handle.list_members_including_retiring().await;
10035 let mut members = Vec::with_capacity(entries.len());
10036 let mut session_owner_by_id = BTreeMap::new();
10037 for entry in &entries {
10038 let identity =
10041 crate::member_comms_id::runtime_alias_str(entry.agent_identity.as_str()).into_owned();
10042 let session_id = read_model.session_id_by_identity.get(&identity).cloned();
10043 if let Some(session_id) = session_id.as_ref() {
10044 session_owner_by_id.insert(session_id.clone(), identity.clone());
10045 }
10046 let model_capabilities =
10047 model_capabilities_for_role(handle.definition(), entry.role.as_str());
10048 let mut labels = entry.labels.clone();
10049 crate::console_spawn::sanitize_unverified_lineage_labels(&mut labels);
10054 let console_identity_key = crate::member_comms_id::durable_identity_label(&entry.labels)
10055 .unwrap_or(identity.as_str());
10056 if let Some(spawn_labels) = registered_labels.get(console_identity_key) {
10057 crate::console_spawn::merge_registered_labels(&mut labels, spawn_labels);
10058 }
10059 if let Some(host_identity) = host_identity {
10060 labels
10061 .entry("delegate_host_identity".to_string())
10062 .or_insert_with(|| host_identity.to_string());
10063 labels
10064 .entry("group".to_string())
10065 .or_insert_with(|| "Coordinators".to_string());
10066 }
10067 if let Some(source_mob_id) = source_mob_id {
10068 labels
10069 .entry("source_mob_id".to_string())
10070 .or_insert_with(|| source_mob_id.to_string());
10071 }
10072 let mut wired_to: Vec<String> = entry
10073 .wired_to
10074 .iter()
10075 .map(|peer| crate::member_comms_id::runtime_alias_str(peer.as_str()).into_owned())
10076 .collect();
10077 if let Some(host_identity) = host_identity
10078 && !wired_to.iter().any(|peer| peer == host_identity)
10079 {
10080 wired_to.push(host_identity.to_string());
10081 }
10082 members.push(ConsoleMember {
10083 agent_identity: identity,
10084 role: entry.role.to_string(),
10085 state: member_status_state_string(entry.status),
10086 model_capabilities,
10087 runtime_mode: Some(entry.runtime_mode.to_string()),
10088 session_id,
10089 wired_to,
10090 labels,
10091 progress: None,
10092 });
10093 }
10094 attach_member_progress(handle, &entries, &mut members).await;
10095 (members, session_owner_by_id)
10096}
10097
10098const CONSOLE_PROGRESS_MEMBER_CAP: usize = 64;
10106
10107fn console_progress_member_cap() -> usize {
10108 std::env::var("MOBKIT_CONSOLE_PROGRESS_MEMBER_CAP")
10109 .ok()
10110 .and_then(|value| value.trim().parse::<usize>().ok())
10111 .unwrap_or(CONSOLE_PROGRESS_MEMBER_CAP)
10112}
10113
10114async fn attach_member_progress(
10118 handle: &MobHandle,
10119 entries: &[meerkat_mob::runtime::MobMemberListEntry],
10120 members: &mut [ConsoleMember],
10121) {
10122 let cap = console_progress_member_cap();
10123 if entries.len() > cap {
10124 tracing::debug!(
10125 member_count = entries.len(),
10126 cap,
10127 "skipping console progress projection: roster exceeds \
10128 MOBKIT_CONSOLE_PROGRESS_MEMBER_CAP"
10129 );
10130 return;
10131 }
10132 for (entry, member) in entries.iter().zip(members.iter_mut()) {
10133 if entry.is_final {
10134 continue;
10135 }
10136 let Ok(snapshot) = handle.member_status(&entry.agent_identity).await else {
10137 continue;
10138 };
10139 member.progress = snapshot
10140 .progress
10141 .as_ref()
10142 .and_then(|progress| serde_json::to_value(progress).ok());
10143 }
10144}
10145
10146async fn build_aggregator_live_snapshot(
10147 aggregator: &MobKitConsoleAggregator,
10148 config_module_ids: &[String],
10149) -> Result<ConsoleLiveSnapshot, Box<dyn std::error::Error + Send + Sync>> {
10150 let identities = Box::pin(aggregator.list_identities()).await?;
10151 let mut members = Vec::with_capacity(identities.len());
10152 for identity in &identities {
10153 let mut labels = identity.labels.clone();
10154 labels
10155 .entry("display_name".to_string())
10156 .or_insert_with(|| identity.display_name.clone());
10157 labels
10158 .entry("addressable".to_string())
10159 .or_insert_with(|| identity.addressable.to_string());
10160 let wired_to = identity.topology_peers.clone();
10164 members.push(ConsoleMember {
10165 agent_identity: identity.identity.clone(),
10166 role: labels
10167 .get("role")
10168 .cloned()
10169 .unwrap_or_else(|| "identity".to_string()),
10170 state: identity.health.clone(),
10171 model_capabilities: ConsoleModelCapabilities::default(),
10172 runtime_mode: Some("console_aggregator".to_string()),
10173 session_id: identity.session_id.clone(),
10174 wired_to,
10175 labels,
10176 progress: None,
10177 });
10178 }
10179 members.sort_by(|left, right| left.agent_identity.cmp(&right.agent_identity));
10180 let agents = members
10181 .iter()
10182 .map(|member| ConsoleAgentLiveSnapshot {
10183 agent_id: member.agent_identity.clone(),
10184 member_id: member.agent_identity.clone(),
10185 label: member
10186 .labels
10187 .get("display_name")
10188 .cloned()
10189 .unwrap_or_else(|| member.agent_identity.clone()),
10190 kind: "meerkat".to_string(),
10191 identity: Some(member.agent_identity.clone()),
10192 role: Some(member.role.clone()),
10193 state: Some(member.state.clone()),
10194 session_id: member.session_id.clone(),
10195 model_capabilities: member.model_capabilities.clone(),
10196 response_phase: None,
10197 watched: None,
10198 alert_level: None,
10199 degraded: None,
10200 degraded_reason: None,
10201 })
10202 .collect::<Vec<_>>();
10203 let loaded_modules = if config_module_ids.is_empty() {
10204 members
10205 .iter()
10206 .map(|member| member.agent_identity.clone())
10207 .collect()
10208 } else {
10209 config_module_ids.to_vec()
10210 };
10211 Ok(ConsoleLiveSnapshot::new(
10212 Some("console-aggregator".to_string()),
10213 true,
10214 loaded_modules,
10215 agents,
10216 members,
10217 true,
10218 ))
10219}
10220
10221pub async fn console_frontend_index_handler() -> impl IntoResponse {
10222 (
10223 [
10224 (header::CONTENT_TYPE, "text/html; charset=utf-8"),
10225 (header::CACHE_CONTROL, "no-store"),
10226 ],
10227 CONSOLE_FRONTEND_INDEX_HTML,
10228 )
10229}
10230
10231pub async fn console_frontend_app_js_handler() -> impl IntoResponse {
10232 (
10233 [
10234 (
10235 header::CONTENT_TYPE,
10236 "application/javascript; charset=utf-8",
10237 ),
10238 (header::CACHE_CONTROL, "no-store"),
10239 ],
10240 CONSOLE_FRONTEND_APP_JS,
10241 )
10242}
10243
10244pub async fn console_frontend_app_css_handler() -> impl IntoResponse {
10245 (
10246 [
10247 (header::CONTENT_TYPE, "text/css; charset=utf-8"),
10248 (header::CACHE_CONTROL, "no-store"),
10249 ],
10250 CONSOLE_FRONTEND_APP_CSS,
10251 )
10252}
10253
10254#[cfg(test)]
10255#[allow(clippy::expect_used, clippy::large_futures)]
10256mod tests {
10257 use super::ConsoleTimelineHttpQuery;
10258 use super::{
10259 ConsoleSnapshotReadModel, ConsoleSnapshotReadModelState, MAX_MULTIPART_BODY_BYTES,
10260 MAX_MULTIPART_IMAGE_BYTES, MultipartImageUpload, apply_console_visibility_policy,
10261 build_aggregator_live_snapshot, collect_console_snapshot_read_model,
10262 console_runtime_alias_generation, console_send_identity_first,
10263 console_send_with_identity_first_fallback, console_timeline_replay_unavailable_response,
10264 cursor_is_after, dedupe_console_members_by_identity, externalize_image_upload_placeholders,
10265 externalize_single_image_upload, handle_console_aggregator_rpc, handle_console_runtime_rpc,
10266 handle_console_runtime_rpc_with_visibility, member_id_matches_durable_identity,
10267 project_console_members_from_handle, query_timeline_snapshot, timeline_query_from_http,
10268 };
10269 use crate::access::{
10270 ACTION_AGENT_MEMORY_DELETE, ACTION_AGENT_MEMORY_READ, ACTION_AGENT_MEMORY_WRITE,
10271 ACTION_AGENT_SEND, ACTION_AGENT_VIEW, AccessController,
10272 };
10273 use crate::blob_store::{BinaryBlobStore, ObjectStoreBlobStore};
10274 use crate::console_aggregator::{
10275 AllowAllConsoleVisibilityPolicy, ConsoleIdentityRecord,
10276 HideImplicitDelegateMembersConsoleVisibilityPolicy,
10277 };
10278 use crate::console_aggregator::{
10279 ConsoleCursor, ConsoleFrameSource, ConsoleFrameSourceKind, ConsoleFrameStatus,
10280 ConsoleTimelineQuery, ConsoleTimelineWindowQuery, ConsoleVisibilityPolicy,
10281 MobKitConsoleAggregator, NewConsoleFrame,
10282 };
10283 use crate::identity_first::contracts::{ContinuityStore, LeaseProvider};
10284 use crate::identity_first::{
10285 AgentAddressability, AgentBuildDraft, AgentIdentity, AgentMemoryConfig,
10286 AgentMemoryRuntimeInjector, AgentRuntimeId, BridgeError, CheckpointVersion,
10287 ContinuityGeneration, ContinuityRecord, DurabilityPolicy, DurableAgentSpec, FencingToken,
10288 IdentityLifecycleState, IdentityRuntime, IdentityRuntimeConfig, LeaseAcquireResult,
10289 LeaseGrant, LocalContinuityStore, LocalLeaseProvider, ManagedPeerEdge,
10290 MarkdownAgentMemoryStore, ResumeSessionOutcome, SessionBridge, SessionSnapshot,
10291 };
10292 use crate::mob_handle_runtime::{MobRuntime, model_capabilities_for_role};
10293 use crate::rpc::{JSONRPC_VERSION, JsonRpcRequest};
10294 use crate::runtime::{ConsoleAgentLiveSnapshot, ConsoleLiveSnapshot, ConsoleMember};
10295 use crate::unified_runtime::ConsoleEventStore;
10296 use crate::{MobBootstrapOptions, MobBootstrapSpec};
10297 use bytes::Bytes;
10298 use meerkat::{AgentFactory, Config, build_ephemeral_service};
10299 use meerkat_client::TestClient;
10300 use meerkat_core::types::HandlingMode;
10301 use meerkat_mob::ProfileName;
10302 use meerkat_mob::{MobDefinition, MobStorage, SpawnMemberSpec};
10303 use serde_json::{Value, json};
10304 use std::collections::BTreeMap;
10305 use std::sync::atomic::{AtomicUsize, Ordering};
10306 use std::sync::{Arc, Mutex};
10307 use std::time::Duration;
10308
10309 struct BlockingIdentityBridge {
10310 deliver_calls: Arc<AtomicUsize>,
10311 }
10312
10313 struct RecordingIdentityBridge {
10314 session_id: meerkat_core::types::SessionId,
10315 handling_modes: Arc<Mutex<Vec<HandlingMode>>>,
10316 }
10317
10318 #[async_trait::async_trait]
10319 impl SessionBridge for BlockingIdentityBridge {
10320 async fn create_session(
10321 &self,
10322 _identity: &AgentIdentity,
10323 _runtime_id: &AgentRuntimeId,
10324 _spec: &DurableAgentSpec,
10325 _draft: &AgentBuildDraft,
10326 session_id: &meerkat_core::types::SessionId,
10327 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
10328 Ok(session_id.clone())
10329 }
10330
10331 async fn resume_session(
10332 &self,
10333 _identity: &AgentIdentity,
10334 _runtime_id: &AgentRuntimeId,
10335 _spec: &DurableAgentSpec,
10336 _draft: &AgentBuildDraft,
10337 session_id: &meerkat_core::types::SessionId,
10338 _snapshot: &SessionSnapshot,
10339 ) -> Result<ResumeSessionOutcome, BridgeError> {
10340 Ok(ResumeSessionOutcome::Resumed {
10341 session_id: session_id.clone(),
10342 })
10343 }
10344
10345 async fn deliver_admitted(
10346 &self,
10347 _runtime_id: &AgentRuntimeId,
10348 _delivery: crate::identity_first::BridgeDelivery,
10349 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
10350 self.deliver_calls.fetch_add(1, Ordering::SeqCst);
10351 std::future::pending().await
10352 }
10353
10354 async fn checkpoint_session(
10355 &self,
10356 _runtime_id: &AgentRuntimeId,
10357 _session_id: &meerkat_core::types::SessionId,
10358 ) -> Result<SessionSnapshot, BridgeError> {
10359 Err(BridgeError::Mob("checkpoint not used in test".to_string()))
10360 }
10361
10362 async fn retire_member(&self, _runtime_id: &AgentRuntimeId) -> Result<(), BridgeError> {
10363 Ok(())
10364 }
10365 }
10366
10367 #[async_trait::async_trait]
10368 impl SessionBridge for RecordingIdentityBridge {
10369 async fn create_session(
10370 &self,
10371 _identity: &AgentIdentity,
10372 _runtime_id: &AgentRuntimeId,
10373 _spec: &DurableAgentSpec,
10374 _draft: &AgentBuildDraft,
10375 session_id: &meerkat_core::types::SessionId,
10376 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
10377 Ok(session_id.clone())
10378 }
10379
10380 async fn resume_session(
10381 &self,
10382 _identity: &AgentIdentity,
10383 _runtime_id: &AgentRuntimeId,
10384 _spec: &DurableAgentSpec,
10385 _draft: &AgentBuildDraft,
10386 session_id: &meerkat_core::types::SessionId,
10387 _snapshot: &SessionSnapshot,
10388 ) -> Result<ResumeSessionOutcome, BridgeError> {
10389 Ok(ResumeSessionOutcome::Resumed {
10390 session_id: session_id.clone(),
10391 })
10392 }
10393
10394 async fn deliver_admitted(
10395 &self,
10396 _runtime_id: &AgentRuntimeId,
10397 delivery: crate::identity_first::BridgeDelivery,
10398 ) -> Result<meerkat_core::types::SessionId, BridgeError> {
10399 self.handling_modes
10400 .lock()
10401 .map_err(|_| BridgeError::Mob("handling modes mutex poisoned".to_string()))?
10402 .push(delivery.handling_mode);
10403 Ok(self.session_id.clone())
10404 }
10405
10406 async fn checkpoint_session(
10407 &self,
10408 _runtime_id: &AgentRuntimeId,
10409 _session_id: &meerkat_core::types::SessionId,
10410 ) -> Result<SessionSnapshot, BridgeError> {
10411 Err(BridgeError::Mob("checkpoint not used in test".to_string()))
10412 }
10413
10414 async fn retire_member(&self, _runtime_id: &AgentRuntimeId) -> Result<(), BridgeError> {
10415 Ok(())
10416 }
10417 }
10418
10419 async fn build_empty_console_test_runtime(
10420 mob_id: &str,
10421 ) -> Result<(tempfile::TempDir, MobRuntime), Box<dyn std::error::Error + Send + Sync>> {
10422 let temp_dir = tempfile::tempdir()?;
10423 let session_path = temp_dir.path().join("sessions");
10424 std::fs::create_dir_all(&session_path)?;
10425 let factory = AgentFactory::new(&session_path).comms(true);
10426 let session_service = Arc::new(build_ephemeral_service(factory, Config::default(), 16));
10427 let definition = MobDefinition::from_toml(&format!(
10428 r#"
10429[mob]
10430id = "{mob_id}"
10431
10432[profiles.worker]
10433model = "gpt-5.5"
10434external_addressable = true
10435
10436[profiles.worker.tools]
10437comms = true
10438"#
10439 ))?;
10440 let runtime = MobRuntime::bootstrap(
10441 MobBootstrapSpec::new(definition, MobStorage::in_memory(), session_service)
10442 .with_options(MobBootstrapOptions {
10443 allow_ephemeral_sessions: true,
10444 notify_orchestrator_on_resume: true,
10445 default_llm_client: Some(Arc::new(TestClient::default())),
10446 }),
10447 )
10448 .await?;
10449 Ok((temp_dir, runtime))
10450 }
10451
10452 fn rpc_request(method: &str) -> JsonRpcRequest {
10453 JsonRpcRequest {
10454 jsonrpc: JSONRPC_VERSION.to_string(),
10455 id: Some(json!(1)),
10456 method: method.to_string(),
10457 params: json!({}),
10458 }
10459 }
10460
10461 fn rpc_request_with_params(method: &str, params: Value) -> JsonRpcRequest {
10462 JsonRpcRequest {
10463 jsonrpc: JSONRPC_VERSION.to_string(),
10464 id: Some(json!(1)),
10465 method: method.to_string(),
10466 params,
10467 }
10468 }
10469
10470 #[tokio::test]
10471 async fn console_storage_doctor_is_a_read_method_and_status_advertises_it()
10472 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10473 let (_temp_dir, runtime) =
10474 build_empty_console_test_runtime("console-storage-doctor").await?;
10475
10476 let doctor_rpc = |params: Value| {
10477 Box::pin(handle_console_runtime_rpc(
10478 &runtime,
10479 None,
10480 None,
10481 None,
10482 None,
10483 None,
10484 None,
10485 None,
10486 None,
10487 rpc_request_with_params("mobkit/storage/doctor", params),
10488 true,
10489 ))
10490 };
10491
10492 let status = Box::pin(handle_console_runtime_rpc(
10494 &runtime,
10495 None,
10496 None,
10497 None,
10498 None,
10499 None,
10500 None,
10501 None,
10502 None,
10503 rpc_request_with_params("mobkit/status", json!({})),
10504 true,
10505 ))
10506 .await;
10507 assert_eq!(
10508 status["result"]["storage"]["doctor_available"],
10509 json!(true),
10510 "{status:#?}"
10511 );
10512
10513 let caps = Box::pin(handle_console_runtime_rpc(
10515 &runtime,
10516 None,
10517 None,
10518 None,
10519 None,
10520 None,
10521 None,
10522 None,
10523 None,
10524 rpc_request_with_params("mobkit/capabilities", json!({})),
10525 true,
10526 ))
10527 .await;
10528 let methods = caps["result"]["methods"].as_array().expect("methods");
10529 assert!(
10530 methods
10531 .iter()
10532 .any(|m| m.as_str() == Some("mobkit/storage/doctor")),
10533 "{methods:?}"
10534 );
10535
10536 let missing = doctor_rpc(json!({})).await;
10538 assert_eq!(missing["error"]["code"], json!(-32004), "{missing:#?}");
10539
10540 let fixture = tempfile::tempdir()?;
10542 std::fs::write(fixture.path().join("sessions.db"), b"")?;
10543 std::fs::write(fixture.path().join("sessions.sqlite"), b"")?;
10544 let resp = doctor_rpc(json!({ "state_dir": fixture.path() })).await;
10545 let findings = resp["result"]["diagnosis"]["findings"]
10546 .as_array()
10547 .expect("findings array");
10548 assert!(
10549 findings
10550 .iter()
10551 .any(|f| f["code"] == "file-name-twins" && f["severity"] == "error"),
10552 "{findings:#?}"
10553 );
10554 Ok(())
10555 }
10556
10557 #[tokio::test]
10558 async fn read_only_aggregator_capabilities_omit_mutating_methods() {
10559 let response = handle_console_aggregator_rpc(
10560 None,
10561 rpc_request("mobkit/capabilities"),
10562 true,
10563 true,
10564 None,
10565 None,
10566 )
10567 .await;
10568
10569 let methods = response["result"]["methods"]
10570 .as_array()
10571 .expect("capabilities methods");
10572 assert!(
10573 methods.iter().all(|method| method != "mobkit/console/send"),
10574 "read-only capabilities must omit send: {methods:#?}"
10575 );
10576 assert_eq!(response["result"]["read_only"], json!(true));
10577 assert_eq!(
10578 response["result"]["runtime_capabilities"]["can_send_messages"],
10579 json!(false)
10580 );
10581 }
10582
10583 #[tokio::test]
10584 async fn read_only_aggregator_denies_direct_mutating_rpc() {
10585 let response = handle_console_aggregator_rpc(
10586 None,
10587 rpc_request_with_params(
10588 "mobkit/console/send",
10589 json!({
10590 "identity": "worker",
10591 "content": "hello",
10592 "origin": "test",
10593 "idempotency_key": "read-only-send",
10594 }),
10595 ),
10596 true,
10597 true,
10598 None,
10599 None,
10600 )
10601 .await;
10602
10603 assert_eq!(response["result"], Value::Null);
10604 assert_eq!(response["error"]["code"], json!(-32010));
10605 assert_eq!(response["error"]["data"]["kind"], json!("read_only"));
10606 }
10607
10608 #[test]
10609 fn read_only_mutating_methods_include_state_draining_collect_completed() {
10610 assert!(super::is_console_mutating_rpc_method(
10611 "mobkit/collect_completed"
10612 ));
10613 }
10614
10615 #[test]
10616 fn authenticated_gating_approver_comes_from_console_principal() {
10617 let forged_params = json!({ "approver_id": "forged-browser-value" });
10618
10619 assert_eq!(
10620 super::resolve_gating_approver_id(&forged_params, Some("admin@example.com"),),
10621 Ok("admin@example.com".to_string()),
10622 );
10623 }
10624
10625 #[test]
10626 fn unauthenticated_local_gating_approver_requires_request_param() {
10627 assert_eq!(
10628 super::resolve_gating_approver_id(&json!({ "approver_id": " local-operator " }), None,),
10629 Ok("local-operator".to_string()),
10630 );
10631 assert_eq!(
10632 super::resolve_gating_approver_id(&json!({}), None),
10633 Err("approver_id required"),
10634 );
10635 }
10636
10637 #[test]
10647 fn internal_error_carries_detail_and_stable_kind() {
10648 let response = super::internal_error(json!(7), "retire failed: backend says no");
10649
10650 assert_eq!(response["error"]["code"], json!(-32000));
10651 assert_eq!(
10652 response["error"]["message"],
10653 json!("retire failed: backend says no")
10654 );
10655 assert_eq!(response["error"]["data"]["error"], json!("internal_error"));
10656 assert_eq!(
10657 response["error"]["data"]["detail"],
10658 json!("retire failed: backend says no")
10659 );
10660 }
10661
10662 #[test]
10663 fn console_send_public_message_hides_dispatch_details() {
10664 let message = super::console_send_public_message(
10665 &crate::console_aggregator::ConsoleSendError::Dispatch(
10666 "secret backend DSN".to_string(),
10667 ),
10668 );
10669
10670 assert_eq!(message, "console send failed");
10671 assert!(!message.contains("secret backend DSN"));
10672 }
10673
10674 #[test]
10675 fn console_send_json_rpc_error_hides_backend_details() {
10676 let response = super::console_send_rpc_error(
10677 json!(7),
10678 crate::console_aggregator::ConsoleSendError::Dispatch("secret backend DSN".to_string()),
10679 );
10680
10681 assert_eq!(response["error"]["code"], json!(-32000));
10682 assert_eq!(response["error"]["message"], json!("console send failed"));
10683 assert!(!response.to_string().contains("secret backend DSN"));
10684 }
10685
10686 #[test]
10687 fn console_timeline_replay_error_hides_backend_details() {
10688 let response = super::console_timeline_replay_unavailable_response(
10689 json!(7),
10690 Box::new(std::io::Error::other("secret backend DSN")),
10691 None,
10692 None,
10693 );
10694
10695 assert_eq!(
10696 response["error"]["code"],
10697 json!(crate::rpc::CONSOLE_TIMELINE_REPLAY_UNAVAILABLE_CODE)
10698 );
10699 assert_eq!(
10700 response["error"]["message"],
10701 json!("timeline replay unavailable")
10702 );
10703 assert!(!response.to_string().contains("secret backend DSN"));
10704 }
10705
10706 #[test]
10707 fn gating_decision_error_hides_backend_details() {
10708 let response = super::gating_decision_failed_error(json!(7), "secret backend DSN");
10709
10710 assert_eq!(response["error"]["code"], json!(-32602));
10711 assert_eq!(
10712 response["error"]["message"],
10713 json!("gating decision failed")
10714 );
10715 assert!(!response.to_string().contains("secret backend DSN"));
10716 }
10717
10718 #[tokio::test]
10719 async fn console_runtime_identity_controls_resolve_durable_member_aliases()
10720 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10721 let (_temp_dir, runtime) =
10722 build_empty_console_test_runtime("console-identity-control-alias").await?;
10723 let mut labels = BTreeMap::new();
10724 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
10725 labels.insert("display_name".to_string(), "Review Agent".to_string());
10726 runtime
10727 .handle()
10728 .spawn_spec(
10729 SpawnMemberSpec::from_wire(
10730 "worker".to_string(),
10731 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
10734 Some("You are Review Agent.".into()),
10735 None,
10736 None,
10737 )
10738 .with_labels(labels),
10739 )
10740 .await?;
10741
10742 let durable_status = Box::pin(handle_console_runtime_rpc(
10743 &runtime,
10744 None,
10745 None,
10746 None,
10747 None,
10748 None,
10749 None,
10750 None,
10751 None,
10752 rpc_request_with_params(
10753 "mobkit/status_identity",
10754 json!({ "identity": "review:singleton" }),
10755 ),
10756 true,
10757 ))
10758 .await;
10759 assert_eq!(durable_status["error"], Value::Null);
10760 assert_eq!(
10761 durable_status["result"]["identity"],
10762 json!("review:singleton")
10763 );
10764 assert_eq!(
10765 durable_status["result"]["agent_runtime_id"],
10766 json!("rt:review:singleton:0")
10767 );
10768
10769 let runtime_id_status = Box::pin(handle_console_runtime_rpc(
10770 &runtime,
10771 None,
10772 None,
10773 None,
10774 None,
10775 None,
10776 None,
10777 None,
10778 None,
10779 rpc_request_with_params(
10780 "mobkit/status_identity",
10781 json!({ "identity": "rt:review:singleton:0" }),
10782 ),
10783 true,
10784 ))
10785 .await;
10786 assert_eq!(runtime_id_status["error"], Value::Null);
10787 assert_eq!(
10788 runtime_id_status["result"]["identity"],
10789 json!("review:singleton")
10790 );
10791
10792 let runtime_id_inspect = Box::pin(handle_console_runtime_rpc(
10793 &runtime,
10794 None,
10795 None,
10796 None,
10797 None,
10798 None,
10799 None,
10800 None,
10801 None,
10802 rpc_request_with_params(
10803 "mobkit/inspect_identity",
10804 json!({ "identity": "rt:review:singleton:0" }),
10805 ),
10806 true,
10807 ))
10808 .await;
10809 assert_eq!(runtime_id_inspect["error"], Value::Null);
10810 assert_eq!(
10811 runtime_id_inspect["result"]["identity"],
10812 json!("review:singleton")
10813 );
10814
10815 let respawn = Box::pin(handle_console_runtime_rpc(
10816 &runtime,
10817 None,
10818 None,
10819 None,
10820 Some(ConsoleEventStore::new()),
10821 None,
10822 None,
10823 None,
10824 None,
10825 rpc_request_with_params("mobkit/respawn", json!({ "identity": "review:singleton" })),
10826 true,
10827 ))
10828 .await;
10829 assert_eq!(respawn["error"], Value::Null);
10830 assert_eq!(respawn["result"]["identity"], json!("review:singleton"));
10831 assert_eq!(
10832 respawn["result"]["agent_runtime_id"],
10833 json!("rt:review:singleton:0")
10834 );
10835
10836 let reset_without_identity_runtime = Box::pin(handle_console_runtime_rpc(
10840 &runtime,
10841 None,
10842 None,
10843 None,
10844 Some(ConsoleEventStore::new()),
10845 None,
10846 None,
10847 None,
10848 None,
10849 rpc_request_with_params("mobkit/reset", json!({ "identity": "review:singleton" })),
10850 true,
10851 ))
10852 .await;
10853 assert_eq!(
10854 reset_without_identity_runtime["error"],
10855 Value::Null,
10856 "{reset_without_identity_runtime:#?}"
10857 );
10858 assert_eq!(
10859 reset_without_identity_runtime["result"]["identity"],
10860 json!("review:singleton")
10861 );
10862 assert_eq!(
10863 reset_without_identity_runtime["result"]["agent_runtime_id"],
10864 json!("rt:review:singleton:0")
10865 );
10866
10867 let reset_unknown_identity = Box::pin(handle_console_runtime_rpc(
10868 &runtime,
10869 None,
10870 None,
10871 None,
10872 Some(ConsoleEventStore::new()),
10873 None,
10874 None,
10875 None,
10876 None,
10877 rpc_request_with_params("mobkit/reset", json!({ "identity": "missing:member" })),
10878 true,
10879 ))
10880 .await;
10881 assert_ne!(reset_unknown_identity["error"], Value::Null);
10882 assert!(
10883 reset_unknown_identity["error"]["message"]
10884 .as_str()
10885 .unwrap_or_default()
10886 .contains("identity not found")
10887 );
10888
10889 let _ = runtime.handle().stop().await;
10890 Ok(())
10891 }
10892
10893 #[tokio::test]
10894 async fn console_runtime_identity_controls_reject_ambiguous_live_label_aliases()
10895 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10896 let (_temp_dir, runtime) =
10897 build_empty_console_test_runtime("console-identity-ambiguous-live-alias").await?;
10898 for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
10899 let mut labels = BTreeMap::new();
10900 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
10901 runtime
10902 .handle()
10903 .spawn_spec(
10904 SpawnMemberSpec::from_wire(
10905 "worker".to_string(),
10906 crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
10909 Some("You are a duplicate Review Agent.".into()),
10910 None,
10911 None,
10912 )
10913 .with_labels(labels),
10914 )
10915 .await?;
10916 }
10917
10918 for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
10919 for method in [
10920 "mobkit/status_identity",
10921 "mobkit/inspect_identity",
10922 "mobkit/retire",
10923 "mobkit/respawn",
10924 "mobkit/reset",
10925 ] {
10926 let response = Box::pin(handle_console_runtime_rpc(
10927 &runtime,
10928 None,
10929 None,
10930 None,
10931 Some(ConsoleEventStore::new()),
10932 None,
10933 None,
10934 None,
10935 None,
10936 rpc_request_with_params(method, json!({ "identity": requested_identity })),
10937 true,
10938 ))
10939 .await;
10940 assert_ne!(
10941 response["error"],
10942 Value::Null,
10943 "{method} must reject ambiguous live alias for {requested_identity}"
10944 );
10945 assert_eq!(
10946 response["error"]["data"]["kind"],
10947 json!("ambiguous_live_identity_alias"),
10948 "unexpected response for {method}/{requested_identity}: {response:#?}"
10949 );
10950 }
10951 }
10952
10953 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
10954 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
10955 lease_provider: Arc::new(LocalLeaseProvider::new()),
10956 runtime_instance_id: "console-identity-ambiguous-live-alias".to_string(),
10957 has_runtime_store: true,
10958 durability_policy: DurabilityPolicy::SyncWriteThrough,
10959 bridge: None,
10960 default_timeout: None,
10961 }));
10962 for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
10963 for method in ["mobkit/reset", "mobkit/delete_identity"] {
10964 let response = Box::pin(handle_console_runtime_rpc(
10965 &runtime,
10966 None,
10967 None,
10968 None,
10969 Some(ConsoleEventStore::new()),
10970 None,
10971 Some(identity_runtime.clone()),
10972 None,
10973 None,
10974 rpc_request_with_params(method, json!({ "identity": requested_identity })),
10975 true,
10976 ))
10977 .await;
10978 assert_ne!(
10979 response["error"],
10980 Value::Null,
10981 "{method} must reject ambiguous live alias for {requested_identity}"
10982 );
10983 assert_eq!(
10984 response["error"]["data"]["kind"],
10985 json!("ambiguous_live_identity_alias"),
10986 "unexpected response for {method}/{requested_identity}: {response:#?}"
10987 );
10988 }
10989 }
10990
10991 let _ = runtime.handle().stop().await;
10992 Ok(())
10993 }
10994
10995 #[tokio::test]
10996 async fn console_runtime_durable_identity_prefers_registered_live_over_duplicate_labels()
10997 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
10998 let (_temp_dir, runtime) =
10999 build_empty_console_test_runtime("console-durable-wins-duplicate-live-labels").await?;
11000 for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
11001 let mut labels = BTreeMap::new();
11002 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
11003 runtime
11004 .handle()
11005 .spawn_spec(
11006 SpawnMemberSpec::from_wire(
11007 "worker".to_string(),
11008 crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
11011 Some("You are a Review Agent candidate.".into()),
11012 None,
11013 None,
11014 )
11015 .with_labels(labels),
11016 )
11017 .await?;
11018 }
11019
11020 let store = Arc::new(LocalContinuityStore::in_memory()?);
11021 let lease_provider = Arc::new(LocalLeaseProvider::new());
11022 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11023 continuity_store: store.clone(),
11024 lease_provider: lease_provider.clone(),
11025 runtime_instance_id: "test-runtime".to_string(),
11026 has_runtime_store: true,
11027 durability_policy: DurabilityPolicy::SyncWriteThrough,
11028 bridge: None,
11029 default_timeout: None,
11030 }));
11031 let identity = AgentIdentity::parse("review:singleton")?;
11032 let registered_session_id = runtime
11033 .handle()
11034 .resolve_bridge_session_id_observation(&crate::member_comms_id::mob_member_id(
11035 "rt:review:singleton:0",
11036 ))
11037 .await
11038 .unwrap_or_else(meerkat_core::types::SessionId::new);
11039 let record = ContinuityRecord {
11040 identity: identity.clone(),
11041 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11042 session_id: registered_session_id,
11043 generation: ContinuityGeneration::new(0),
11044 checkpoint_version: CheckpointVersion::new(0),
11045 };
11046 let grants = lease_provider
11047 .acquire_leases(std::slice::from_ref(&identity), "test-runtime")
11048 .await?;
11049 let grant = match grants.get(&identity).cloned() {
11050 Some(LeaseAcquireResult::Acquired(grant)) => grant,
11051 other => return Err(format!("expected acquired lease, got {other:?}").into()),
11052 };
11053 store
11054 .upsert_continuity_record(&record, grant.fencing_token)
11055 .await?;
11056 identity_runtime
11057 .register(
11058 DurableAgentSpec {
11059 identity: identity.clone(),
11060 profile: ProfileName::from("worker"),
11061 addressability: AgentAddressability::Addressable,
11062 display_name: None,
11063 labels: BTreeMap::new(),
11064 context: None,
11065 additional_instructions: Vec::new(),
11066 initial_message: None,
11067 runtime_mode_override: None,
11068 backend: None,
11069 binding: None,
11070 },
11071 IdentityLifecycleState::Active,
11072 Some(record),
11073 Some(grant),
11074 )
11075 .await;
11076
11077 for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
11078 for method in ["mobkit/status_identity", "mobkit/inspect_identity"] {
11079 let response = Box::pin(handle_console_runtime_rpc(
11080 &runtime,
11081 None,
11082 None,
11083 None,
11084 Some(ConsoleEventStore::new()),
11085 None,
11086 Some(identity_runtime.clone()),
11087 None,
11088 None,
11089 rpc_request_with_params(method, json!({ "identity": requested_identity })),
11090 true,
11091 ))
11092 .await;
11093 assert_eq!(
11094 response["error"],
11095 Value::Null,
11096 "{method} must use durable registered live binding despite duplicate labels for {requested_identity}: {response:#?}"
11097 );
11098 }
11099 }
11100 let reset_all_response = Box::pin(handle_console_runtime_rpc(
11101 &runtime,
11102 None,
11103 None,
11104 None,
11105 Some(ConsoleEventStore::new()),
11106 None,
11107 Some(identity_runtime.clone()),
11108 None,
11109 None,
11110 rpc_request("mobkit/reset_all"),
11111 true,
11112 ))
11113 .await;
11114 assert_eq!(
11115 reset_all_response["error"],
11116 Value::Null,
11117 "reset_all must also prefer the durable registered live binding despite duplicate labels: {reset_all_response:#?}"
11118 );
11119 assert!(
11120 reset_all_response["result"]["failed"]
11121 .as_array()
11122 .is_some_and(Vec::is_empty),
11123 "reset_all should not report duplicate-label failure for durable registered binding: {reset_all_response:#?}"
11124 );
11125
11126 let _ = runtime.handle().stop().await;
11127 Ok(())
11128 }
11129
11130 #[tokio::test]
11131 async fn console_runtime_identity_controls_reject_wrong_projected_live_only_alias()
11132 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11133 let (_temp_dir, runtime) =
11134 build_empty_console_test_runtime("console-identity-wrong-projected-live-only").await?;
11135
11136 let mut labels = BTreeMap::new();
11137 labels.insert("agent_identity".to_string(), "other:singleton".to_string());
11138 runtime
11139 .handle()
11140 .spawn_spec(
11141 SpawnMemberSpec::from_wire(
11142 "worker".to_string(),
11143 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
11146 Some("You are a wrong-projected Review Agent.".into()),
11147 None,
11148 None,
11149 )
11150 .with_labels(labels),
11151 )
11152 .await?;
11153
11154 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11155 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11156 lease_provider: Arc::new(LocalLeaseProvider::new()),
11157 runtime_instance_id: "console-identity-wrong-projected-live-only".to_string(),
11158 has_runtime_store: true,
11159 durability_policy: DurabilityPolicy::SyncWriteThrough,
11160 bridge: None,
11161 default_timeout: None,
11162 }));
11163 let identity = AgentIdentity::parse("review:singleton")?;
11164 let record = ContinuityRecord {
11165 identity: identity.clone(),
11166 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11167 session_id: meerkat_core::types::SessionId::new(),
11168 generation: ContinuityGeneration::new(0),
11169 checkpoint_version: CheckpointVersion::new(0),
11170 };
11171 identity_runtime
11172 .register(
11173 DurableAgentSpec {
11174 identity: identity.clone(),
11175 profile: ProfileName::from("worker"),
11176 addressability: AgentAddressability::Addressable,
11177 display_name: None,
11178 labels: BTreeMap::new(),
11179 context: None,
11180 additional_instructions: Vec::new(),
11181 initial_message: None,
11182 runtime_mode_override: None,
11183 backend: None,
11184 binding: None,
11185 },
11186 IdentityLifecycleState::Active,
11187 Some(record),
11188 Some(LeaseGrant {
11189 identity,
11190 fencing_token: FencingToken::new(1),
11191 ttl: Duration::from_mins(1),
11192 }),
11193 )
11194 .await;
11195
11196 for method in [
11197 "mobkit/status_identity",
11198 "mobkit/inspect_identity",
11199 "mobkit/retire",
11200 ] {
11201 let response = Box::pin(handle_console_runtime_rpc(
11202 &runtime,
11203 None,
11204 None,
11205 None,
11206 Some(ConsoleEventStore::new()),
11207 None,
11208 Some(identity_runtime.clone()),
11209 None,
11210 None,
11211 rpc_request_with_params(method, json!({ "identity": "other:singleton" })),
11212 true,
11213 ))
11214 .await;
11215 assert_ne!(
11216 response["error"],
11217 Value::Null,
11218 "{method} must reject wrong-projected live-only alias"
11219 );
11220 assert_eq!(
11221 response["error"]["data"]["kind"],
11222 json!("stale_live_identity_alias"),
11223 "unexpected response for {method}: {response:#?}"
11224 );
11225 }
11226 assert!(
11227 runtime
11228 .handle()
11229 .get_member(&crate::member_comms_id::mob_member_id(
11230 "rt:review:singleton:0"
11231 ))
11232 .await
11233 .ok()
11234 .flatten()
11235 .is_some(),
11236 "wrong-projected durable runtime member must not be retired through projected alias"
11237 );
11238
11239 let _ = runtime.handle().stop().await;
11240 Ok(())
11241 }
11242
11243 #[derive(Debug)]
11244 struct HideIdentityPolicy(&'static str);
11245
11246 impl ConsoleVisibilityPolicy for HideIdentityPolicy {
11247 fn identity_visible(&self, record: &ConsoleIdentityRecord) -> bool {
11248 record.identity != self.0
11249 }
11250 }
11251
11252 #[derive(Debug)]
11253 struct HideMemberPolicy(&'static str);
11254
11255 impl ConsoleVisibilityPolicy for HideMemberPolicy {
11256 fn member_visible(&self, member: &ConsoleMember) -> bool {
11257 member.agent_identity != self.0
11258 && member
11259 .labels
11260 .get("agent_identity")
11261 .is_none_or(|identity| identity != self.0)
11262 }
11263
11264 fn identity_visible(&self, record: &ConsoleIdentityRecord) -> bool {
11265 record.runtime_member_id != self.0
11266 }
11267 }
11268
11269 #[derive(Debug)]
11270 struct HideOnlyMemberPolicy(&'static str);
11271
11272 impl ConsoleVisibilityPolicy for HideOnlyMemberPolicy {
11273 fn member_visible(&self, member: &ConsoleMember) -> bool {
11274 member.agent_identity != self.0
11275 }
11276 }
11277
11278 #[tokio::test]
11279 async fn console_runtime_identity_controls_respect_visibility_policy()
11280 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11281 let (_temp_dir, runtime) =
11282 build_empty_console_test_runtime("console-identity-hidden-controls").await?;
11283 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11284 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11285 lease_provider: Arc::new(LocalLeaseProvider::new()),
11286 runtime_instance_id: "console-identity-hidden-controls".to_string(),
11287 has_runtime_store: true,
11288 durability_policy: DurabilityPolicy::SyncWriteThrough,
11289 bridge: None,
11290 default_timeout: None,
11291 }));
11292 let identity = AgentIdentity::parse("review:singleton")?;
11293 let record = ContinuityRecord {
11294 identity: identity.clone(),
11295 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11296 session_id: meerkat_core::types::SessionId::new(),
11297 generation: ContinuityGeneration::new(0),
11298 checkpoint_version: CheckpointVersion::new(0),
11299 };
11300 identity_runtime
11301 .register(
11302 DurableAgentSpec {
11303 identity: identity.clone(),
11304 profile: ProfileName::from("worker"),
11305 addressability: AgentAddressability::Addressable,
11306 display_name: None,
11307 labels: BTreeMap::new(),
11308 context: None,
11309 additional_instructions: Vec::new(),
11310 initial_message: None,
11311 runtime_mode_override: None,
11312 backend: None,
11313 binding: None,
11314 },
11315 IdentityLifecycleState::Active,
11316 Some(record),
11317 Some(LeaseGrant {
11318 identity: identity.clone(),
11319 fencing_token: FencingToken::new(7),
11320 ttl: Duration::from_mins(1),
11321 }),
11322 )
11323 .await;
11324
11325 for method in [
11326 "mobkit/status_identity",
11327 "mobkit/inspect_identity",
11328 "mobkit/retire",
11329 "mobkit/respawn",
11330 "mobkit/reset",
11331 "mobkit/delete_identity",
11332 ] {
11333 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
11334 &runtime,
11335 None,
11336 None,
11337 None,
11338 Some(ConsoleEventStore::new()),
11339 None,
11340 Some(identity_runtime.clone()),
11341 None,
11342 None,
11343 &HideIdentityPolicy("review:singleton"),
11344 rpc_request_with_params(method, json!({ "identity": "review:singleton" })),
11345 true,
11346 false,
11347 None,
11348 None,
11349 None,
11350 None,
11351 None,
11352 None,
11353 None,
11354 ))
11355 .await;
11356 assert_ne!(
11357 response["error"],
11358 Value::Null,
11359 "{method} must reject hidden durable identity"
11360 );
11361 assert_eq!(
11362 response["error"]["data"]["kind"],
11363 json!("identity_hidden_by_policy"),
11364 "unexpected hidden response for {method}: {response:#?}"
11365 );
11366 }
11367 identity_runtime
11368 .status(&AgentIdentity::parse("review:singleton")?)
11369 .await
11370 .expect("hidden control RPCs must not mutate the durable identity");
11371
11372 let _ = runtime.handle().stop().await;
11373 Ok(())
11374 }
11375
11376 #[tokio::test]
11377 async fn console_runtime_durable_identity_controls_reject_hidden_bound_member()
11378 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11379 let (_temp_dir, runtime) =
11380 build_empty_console_test_runtime("console-durable-hidden-bound-member").await?;
11381 runtime
11382 .handle()
11383 .spawn_spec(SpawnMemberSpec::from_wire(
11384 "worker".to_string(),
11385 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
11388 Some("You are the live Review Agent.".into()),
11389 None,
11390 None,
11391 ))
11392 .await?;
11393
11394 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11395 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11396 lease_provider: Arc::new(LocalLeaseProvider::new()),
11397 runtime_instance_id: "console-durable-hidden-bound-member".to_string(),
11398 has_runtime_store: true,
11399 durability_policy: DurabilityPolicy::SyncWriteThrough,
11400 bridge: None,
11401 default_timeout: None,
11402 }));
11403 let identity = AgentIdentity::parse("review:singleton")?;
11404 let record = ContinuityRecord {
11405 identity: identity.clone(),
11406 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11407 session_id: meerkat_core::types::SessionId::new(),
11408 generation: ContinuityGeneration::new(0),
11409 checkpoint_version: CheckpointVersion::new(0),
11410 };
11411 identity_runtime
11412 .register(
11413 DurableAgentSpec {
11414 identity: identity.clone(),
11415 profile: ProfileName::from("worker"),
11416 addressability: AgentAddressability::Addressable,
11417 display_name: None,
11418 labels: BTreeMap::new(),
11419 context: None,
11420 additional_instructions: Vec::new(),
11421 initial_message: None,
11422 runtime_mode_override: None,
11423 backend: None,
11424 binding: None,
11425 },
11426 IdentityLifecycleState::Active,
11427 Some(record),
11428 Some(LeaseGrant {
11429 identity: identity.clone(),
11430 fencing_token: FencingToken::new(7),
11431 ttl: Duration::from_mins(1),
11432 }),
11433 )
11434 .await;
11435
11436 for requested_identity in ["review:singleton", "rt:review:singleton:0"] {
11437 for method in [
11438 "mobkit/status_identity",
11439 "mobkit/inspect_identity",
11440 "mobkit/retire",
11441 "mobkit/respawn",
11442 "mobkit/reset",
11443 "mobkit/delete_identity",
11444 ] {
11445 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
11446 &runtime,
11447 None,
11448 None,
11449 None,
11450 Some(ConsoleEventStore::new()),
11451 None,
11452 Some(identity_runtime.clone()),
11453 None,
11454 None,
11455 &HideOnlyMemberPolicy("rt:review:singleton:0"),
11456 rpc_request_with_params(method, json!({ "identity": requested_identity })),
11457 true,
11458 false,
11459 None,
11460 None,
11461 None,
11462 None,
11463 None,
11464 None,
11465 None,
11466 ))
11467 .await;
11468 assert_eq!(
11469 response["error"]["data"]["kind"],
11470 json!("identity_hidden_by_policy"),
11471 "durable {method} must reject hidden bound live member for {requested_identity}: {response:#?}"
11472 );
11473 }
11474 }
11475
11476 let _ = runtime.handle().stop().await;
11477 Ok(())
11478 }
11479
11480 #[tokio::test]
11481 async fn console_runtime_live_only_identity_controls_respect_visibility_policy()
11482 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11483 let (_temp_dir, runtime) =
11484 build_empty_console_test_runtime("console-live-only-hidden-controls").await?;
11485
11486 let mut labels = BTreeMap::new();
11487 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
11488 runtime
11489 .handle()
11490 .spawn_spec(
11491 SpawnMemberSpec::from_wire(
11492 "worker".to_string(),
11493 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
11496 Some("You are the live Review Agent.".into()),
11497 None,
11498 None,
11499 )
11500 .with_labels(labels),
11501 )
11502 .await?;
11503
11504 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11505 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11506 lease_provider: Arc::new(LocalLeaseProvider::new()),
11507 runtime_instance_id: "console-live-only-hidden-controls".to_string(),
11508 has_runtime_store: true,
11509 durability_policy: DurabilityPolicy::SyncWriteThrough,
11510 bridge: None,
11511 default_timeout: None,
11512 }));
11513
11514 for method in [
11515 "mobkit/status_identity",
11516 "mobkit/inspect_identity",
11517 "mobkit/retire",
11518 "mobkit/respawn",
11519 "mobkit/reset",
11520 "mobkit/delete_identity",
11521 ] {
11522 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
11523 &runtime,
11524 None,
11525 None,
11526 None,
11527 Some(ConsoleEventStore::new()),
11528 None,
11529 Some(identity_runtime.clone()),
11530 None,
11531 None,
11532 &HideMemberPolicy("rt:review:singleton:0"),
11533 rpc_request_with_params(method, json!({ "identity": "review:singleton" })),
11534 true,
11535 false,
11536 None,
11537 None,
11538 None,
11539 None,
11540 None,
11541 None,
11542 None,
11543 ))
11544 .await;
11545 assert_ne!(
11546 response["error"],
11547 Value::Null,
11548 "{method} must reject hidden live-only identity"
11549 );
11550 assert_eq!(
11551 response["error"]["data"]["kind"],
11552 json!("identity_hidden_by_policy"),
11553 "unexpected hidden live-only response for {method}: {response:#?}"
11554 );
11555 }
11556 assert!(
11557 runtime
11558 .handle()
11559 .get_member(&crate::member_comms_id::mob_member_id(
11560 "rt:review:singleton:0"
11561 ))
11562 .await
11563 .ok()
11564 .flatten()
11565 .is_some(),
11566 "hidden live-only controls must not mutate the live member"
11567 );
11568
11569 let _ = runtime.handle().stop().await;
11570 Ok(())
11571 }
11572
11573 #[tokio::test]
11574 async fn console_runtime_reset_generated_alias_without_registration_fails_closed()
11575 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11576 let (_temp_dir, runtime) =
11577 build_empty_console_test_runtime("console-reset-live-only-no-bridge").await?;
11578
11579 let mut labels = BTreeMap::new();
11580 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
11581 runtime
11582 .handle()
11583 .spawn_spec(
11584 SpawnMemberSpec::from_wire(
11585 "worker".to_string(),
11586 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
11589 Some("You are the live Review Agent.".into()),
11590 None,
11591 None,
11592 )
11593 .with_labels(labels),
11594 )
11595 .await?;
11596
11597 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11598 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11599 lease_provider: Arc::new(LocalLeaseProvider::new()),
11600 runtime_instance_id: "console-reset-live-only-no-bridge".to_string(),
11601 has_runtime_store: true,
11602 durability_policy: DurabilityPolicy::SyncWriteThrough,
11603 bridge: None,
11604 default_timeout: None,
11605 }));
11606
11607 let member_id = crate::member_comms_id::mob_member_id("rt:review:singleton:0");
11608 let session_before = runtime
11609 .handle()
11610 .resolve_bridge_session_id_observation(&member_id)
11611 .await;
11612 let response = Box::pin(handle_console_runtime_rpc(
11613 &runtime,
11614 None,
11615 None,
11616 None,
11617 Some(ConsoleEventStore::new()),
11618 None,
11619 Some(identity_runtime),
11620 None,
11621 None,
11622 rpc_request_with_params("mobkit/reset", json!({ "identity": "review:singleton" })),
11623 true,
11624 ))
11625 .await;
11626 assert_ne!(
11627 response["error"],
11628 Value::Null,
11629 "a generated alias must not become raw fallback after registration disappears: {response:#?}"
11630 );
11631 assert_eq!(
11632 runtime
11633 .handle()
11634 .resolve_bridge_session_id_observation(&member_id)
11635 .await,
11636 session_before,
11637 "failing closed must not respawn the orphaned generated member"
11638 );
11639
11640 let _ = runtime.handle().stop().await;
11641 Ok(())
11642 }
11643
11644 #[tokio::test]
11645 async fn console_runtime_reset_bare_live_member_without_session_bridge_uses_fallback()
11646 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11647 let (_temp_dir, runtime) =
11648 build_empty_console_test_runtime("console-reset-bare-live-no-bridge").await?;
11649
11650 let mut labels = BTreeMap::new();
11651 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
11652 runtime
11653 .handle()
11654 .spawn_spec(
11655 SpawnMemberSpec::from_wire(
11656 "worker".to_string(),
11657 "legacy-review-singleton".to_string(),
11658 Some("You are the live Review Agent.".into()),
11659 None,
11660 None,
11661 )
11662 .with_labels(labels),
11663 )
11664 .await?;
11665
11666 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11667 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11668 lease_provider: Arc::new(LocalLeaseProvider::new()),
11669 runtime_instance_id: "console-reset-bare-live-no-bridge".to_string(),
11670 has_runtime_store: true,
11671 durability_policy: DurabilityPolicy::SyncWriteThrough,
11672 bridge: None,
11673 default_timeout: None,
11674 }));
11675
11676 let response = Box::pin(handle_console_runtime_rpc(
11677 &runtime,
11678 None,
11679 None,
11680 None,
11681 Some(ConsoleEventStore::new()),
11682 None,
11683 Some(identity_runtime),
11684 None,
11685 None,
11686 rpc_request_with_params("mobkit/reset", json!({ "identity": "review:singleton" })),
11687 true,
11688 ))
11689 .await;
11690 assert_eq!(
11691 response["error"],
11692 Value::Null,
11693 "genuine bare live-only reset should retain compatibility fallback: {response:#?}"
11694 );
11695 assert_eq!(response["result"]["identity"], json!("review:singleton"));
11696
11697 let _ = runtime.handle().stop().await;
11698 Ok(())
11699 }
11700
11701 #[tokio::test]
11702 async fn reset_all_rejects_registered_runtime_projected_under_wrong_identity()
11703 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11704 let (_temp_dir, runtime) =
11705 build_empty_console_test_runtime("console-reset-all-stale-projection").await?;
11706
11707 let mut labels = BTreeMap::new();
11708 labels.insert("agent_identity".to_string(), "other:singleton".to_string());
11709 runtime
11710 .handle()
11711 .spawn_spec(
11712 SpawnMemberSpec::from_wire(
11713 "worker".to_string(),
11714 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
11717 Some("You are a mislabeled Review Agent.".into()),
11718 None,
11719 None,
11720 )
11721 .with_labels(labels),
11722 )
11723 .await?;
11724
11725 let store = Arc::new(LocalContinuityStore::in_memory()?);
11726 let lease_provider = Arc::new(LocalLeaseProvider::new());
11727 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11728 continuity_store: store.clone(),
11729 lease_provider: lease_provider.clone(),
11730 runtime_instance_id: "test-runtime".to_string(),
11731 has_runtime_store: true,
11732 durability_policy: DurabilityPolicy::SyncWriteThrough,
11733 bridge: None,
11734 default_timeout: None,
11735 }));
11736 let identity = AgentIdentity::parse("review:singleton")?;
11737 let record = ContinuityRecord {
11738 identity: identity.clone(),
11739 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11740 session_id: meerkat_core::types::SessionId::new(),
11741 generation: ContinuityGeneration::new(0),
11742 checkpoint_version: CheckpointVersion::new(0),
11743 };
11744 let grants = lease_provider
11745 .acquire_leases(std::slice::from_ref(&identity), "test-runtime")
11746 .await?;
11747 let grant = match grants.get(&identity).cloned() {
11748 Some(LeaseAcquireResult::Acquired(grant)) => grant,
11749 other => return Err(format!("expected acquired lease, got {other:?}").into()),
11750 };
11751 store
11752 .upsert_continuity_record(&record, grant.fencing_token)
11753 .await?;
11754 identity_runtime
11755 .register(
11756 DurableAgentSpec {
11757 identity: identity.clone(),
11758 profile: ProfileName::from("worker"),
11759 addressability: AgentAddressability::Addressable,
11760 display_name: None,
11761 labels: BTreeMap::new(),
11762 context: None,
11763 additional_instructions: Vec::new(),
11764 initial_message: None,
11765 runtime_mode_override: None,
11766 backend: None,
11767 binding: None,
11768 },
11769 IdentityLifecycleState::Active,
11770 Some(record),
11771 Some(grant),
11772 )
11773 .await;
11774
11775 let response = Box::pin(handle_console_runtime_rpc(
11776 &runtime,
11777 None,
11778 None,
11779 None,
11780 Some(ConsoleEventStore::new()),
11781 None,
11782 Some(identity_runtime),
11783 None,
11784 None,
11785 rpc_request("mobkit/reset_all"),
11786 true,
11787 ))
11788 .await;
11789 assert_ne!(response["error"], Value::Null);
11790 let failed = response["error"]["data"]["failed"]
11791 .as_array()
11792 .expect("reset_all should report failed identities");
11793 let stale_failure = failed
11794 .iter()
11795 .find(|failure| failure["identity"] == json!("review:singleton"))
11796 .expect("review identity should fail stale alias validation");
11797 assert_eq!(
11798 stale_failure["kind"],
11799 json!("stale_live_identity_alias"),
11800 "unexpected reset_all response: {response:#?}"
11801 );
11802 assert!(
11803 stale_failure["error"]
11804 .as_str()
11805 .unwrap_or_default()
11806 .contains("projects identity other:singleton"),
11807 "unexpected stale failure: {stale_failure:#?}"
11808 );
11809 let retired = response["error"]["data"]["retired_delegates"]
11810 .as_array()
11811 .expect("reset_all should return retired delegates");
11812 assert!(
11813 !retired
11814 .iter()
11815 .any(|entry| entry["identity"] == json!("other:singleton")),
11816 "wrong-projected live alias must not be destructively retired before stale validation; response: {response:#?}"
11817 );
11818 assert!(
11819 runtime
11820 .handle()
11821 .get_member(&crate::member_comms_id::mob_member_id(
11822 "rt:review:singleton:0"
11823 ))
11824 .await
11825 .ok()
11826 .flatten()
11827 .is_some(),
11828 "wrong-projected durable runtime member must remain present after reset_all rejection"
11829 );
11830
11831 let _ = runtime.handle().stop().await;
11832 Ok(())
11833 }
11834
11835 #[tokio::test]
11836 async fn reset_all_respects_console_visibility_policy_for_live_members()
11837 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11838 let (_temp_dir, runtime) =
11839 build_empty_console_test_runtime("console-reset-all-hidden-live").await?;
11840
11841 runtime
11842 .handle()
11843 .spawn_spec(
11844 SpawnMemberSpec::from_wire(
11845 "worker".to_string(),
11846 crate::member_comms_id::mob_member_id_str("hidden:singleton").into_owned(),
11849 Some("You are hidden from console lifecycle controls.".into()),
11850 None,
11851 None,
11852 )
11853 .with_labels(BTreeMap::from([(
11854 "agent_identity".to_string(),
11855 "hidden:singleton".to_string(),
11856 )])),
11857 )
11858 .await?;
11859
11860 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
11861 &runtime,
11862 None,
11863 None,
11864 None,
11865 Some(ConsoleEventStore::new()),
11866 None,
11867 None,
11868 None,
11869 None,
11870 &HideMemberPolicy("hidden:singleton"),
11871 rpc_request("mobkit/reset_all"),
11872 true,
11873 false,
11874 None,
11875 None,
11876 None,
11877 None,
11878 None,
11879 None,
11880 None,
11881 ))
11882 .await;
11883 assert_eq!(
11884 response["error"],
11885 Value::Null,
11886 "hidden live member should be outside reset_all target set: {response:#?}"
11887 );
11888 assert!(
11889 runtime
11890 .handle()
11891 .get_member(&crate::member_comms_id::mob_member_id("hidden:singleton"))
11892 .await
11893 .ok()
11894 .flatten()
11895 .is_some(),
11896 "reset_all must not retire hidden live members"
11897 );
11898
11899 let _ = runtime.handle().stop().await;
11900 Ok(())
11901 }
11902
11903 #[tokio::test]
11904 async fn reset_all_skips_durable_identity_with_hidden_bound_member()
11905 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
11906 let (_temp_dir, runtime) =
11907 build_empty_console_test_runtime("console-reset-all-hidden-durable-bound").await?;
11908 runtime
11909 .handle()
11910 .spawn_spec(SpawnMemberSpec::from_wire(
11911 "worker".to_string(),
11912 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
11915 Some("You are the hidden Review Agent.".into()),
11916 None,
11917 None,
11918 ))
11919 .await?;
11920
11921 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
11922 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
11923 lease_provider: Arc::new(LocalLeaseProvider::new()),
11924 runtime_instance_id: "console-reset-all-hidden-durable-bound".to_string(),
11925 has_runtime_store: true,
11926 durability_policy: DurabilityPolicy::SyncWriteThrough,
11927 bridge: None,
11928 default_timeout: None,
11929 }));
11930 let identity = AgentIdentity::parse("review:singleton")?;
11931 identity_runtime
11932 .register(
11933 DurableAgentSpec {
11934 identity: identity.clone(),
11935 profile: ProfileName::from("worker"),
11936 addressability: AgentAddressability::Addressable,
11937 display_name: None,
11938 labels: BTreeMap::new(),
11939 context: None,
11940 additional_instructions: Vec::new(),
11941 initial_message: None,
11942 runtime_mode_override: None,
11943 backend: None,
11944 binding: None,
11945 },
11946 IdentityLifecycleState::Active,
11947 Some(ContinuityRecord {
11948 identity: identity.clone(),
11949 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
11950 session_id: meerkat_core::types::SessionId::new(),
11951 generation: ContinuityGeneration::new(0),
11952 checkpoint_version: CheckpointVersion::new(0),
11953 }),
11954 Some(LeaseGrant {
11955 identity: identity.clone(),
11956 fencing_token: FencingToken::new(9),
11957 ttl: Duration::from_mins(1),
11958 }),
11959 )
11960 .await;
11961
11962 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
11963 &runtime,
11964 None,
11965 None,
11966 None,
11967 Some(ConsoleEventStore::new()),
11968 None,
11969 Some(identity_runtime.clone()),
11970 None,
11971 None,
11972 &HideOnlyMemberPolicy("rt:review:singleton:0"),
11973 rpc_request("mobkit/reset_all"),
11974 true,
11975 false,
11976 None,
11977 None,
11978 None,
11979 None,
11980 None,
11981 None,
11982 None,
11983 ))
11984 .await;
11985 assert_eq!(
11986 response["error"],
11987 Value::Null,
11988 "hidden durable bound member should be outside reset_all target set: {response:#?}"
11989 );
11990 assert_eq!(
11991 identity_runtime.status(&identity).await?.state,
11992 IdentityLifecycleState::Active
11993 );
11994
11995 let _ = runtime.handle().stop().await;
11996 Ok(())
11997 }
11998
11999 #[tokio::test]
12000 async fn identity_lifecycle_cleanup_skips_hidden_projected_duplicates()
12001 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12002 let (_temp_dir, runtime) =
12003 build_empty_console_test_runtime("console-hidden-stale-duplicate-cleanup").await?;
12004 for runtime_id in ["rt:review:singleton:0", "rt:review:singleton:1"] {
12005 runtime
12006 .handle()
12007 .spawn_spec(
12008 SpawnMemberSpec::from_wire(
12009 "worker".to_string(),
12010 crate::member_comms_id::mob_member_id_str(runtime_id).into_owned(),
12013 Some("You are a Review Agent candidate.".into()),
12014 None,
12015 None,
12016 )
12017 .with_labels(BTreeMap::from([(
12018 "agent_identity".to_string(),
12019 "review:singleton".to_string(),
12020 )])),
12021 )
12022 .await?;
12023 }
12024
12025 let store = Arc::new(LocalContinuityStore::in_memory()?);
12026 let lease_provider = Arc::new(LocalLeaseProvider::new());
12027 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12028 continuity_store: store.clone(),
12029 lease_provider: lease_provider.clone(),
12030 runtime_instance_id: "console-hidden-stale-duplicate-cleanup".to_string(),
12031 has_runtime_store: true,
12032 durability_policy: DurabilityPolicy::SyncWriteThrough,
12033 bridge: None,
12034 default_timeout: None,
12035 }));
12036 let identity = AgentIdentity::parse("review:singleton")?;
12037 let record = ContinuityRecord {
12038 identity: identity.clone(),
12039 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:0")?,
12040 session_id: runtime
12041 .handle()
12042 .resolve_bridge_session_id_observation(&crate::member_comms_id::mob_member_id(
12043 "rt:review:singleton:0",
12044 ))
12045 .await
12046 .unwrap_or_else(meerkat_core::types::SessionId::new),
12047 generation: ContinuityGeneration::new(0),
12048 checkpoint_version: CheckpointVersion::new(0),
12049 };
12050 let grants = lease_provider
12051 .acquire_leases(
12052 std::slice::from_ref(&identity),
12053 "console-hidden-stale-duplicate-cleanup",
12054 )
12055 .await?;
12056 let grant = match grants.get(&identity).cloned() {
12057 Some(LeaseAcquireResult::Acquired(grant)) => grant,
12058 other => return Err(format!("expected acquired lease, got {other:?}").into()),
12059 };
12060 store
12061 .upsert_continuity_record(&record, grant.fencing_token)
12062 .await?;
12063 identity_runtime
12064 .register(
12065 DurableAgentSpec {
12066 identity: identity.clone(),
12067 profile: ProfileName::from("worker"),
12068 addressability: AgentAddressability::Addressable,
12069 display_name: None,
12070 labels: BTreeMap::new(),
12071 context: None,
12072 additional_instructions: Vec::new(),
12073 initial_message: None,
12074 runtime_mode_override: None,
12075 backend: None,
12076 binding: None,
12077 },
12078 IdentityLifecycleState::Active,
12079 Some(record),
12080 Some(grant),
12081 )
12082 .await;
12083
12084 let response = Box::pin(handle_console_runtime_rpc_with_visibility(
12085 &runtime,
12086 None,
12087 None,
12088 None,
12089 Some(ConsoleEventStore::new()),
12090 None,
12091 Some(identity_runtime),
12092 None,
12093 None,
12094 &HideOnlyMemberPolicy("rt:review:singleton:1"),
12095 rpc_request_with_params("mobkit/retire", json!({ "identity": "review:singleton" })),
12096 true,
12097 false,
12098 None,
12099 None,
12100 None,
12101 None,
12102 None,
12103 None,
12104 None,
12105 ))
12106 .await;
12107 assert_eq!(
12108 response["error"],
12109 Value::Null,
12110 "visible durable retire should succeed without touching hidden duplicate: {response:#?}"
12111 );
12112 assert!(
12113 runtime
12114 .handle()
12115 .get_member(&crate::member_comms_id::mob_member_id(
12116 "rt:review:singleton:1"
12117 ))
12118 .await
12119 .ok()
12120 .flatten()
12121 .is_some(),
12122 "post-mutation stale cleanup must not retire member-hidden projected duplicates"
12123 );
12124
12125 let _ = runtime.handle().stop().await;
12126 Ok(())
12127 }
12128
12129 #[tokio::test]
12130 async fn console_runtime_capabilities_advertise_identity_controls_when_identity_runtime_exists()
12131 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12132 let (_temp_dir, runtime) =
12133 build_empty_console_test_runtime("console-identity-capabilities").await?;
12134 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12135 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12136 lease_provider: Arc::new(LocalLeaseProvider::new()),
12137 runtime_instance_id: "console-identity-capabilities".to_string(),
12138 has_runtime_store: true,
12139 durability_policy: DurabilityPolicy::SyncWriteThrough,
12140 bridge: None,
12141 default_timeout: None,
12142 }));
12143
12144 let response = Box::pin(handle_console_runtime_rpc(
12145 &runtime,
12146 None,
12147 None,
12148 None,
12149 None,
12150 None,
12151 Some(identity_runtime.clone()),
12152 None,
12153 None,
12154 rpc_request("mobkit/capabilities"),
12155 true,
12156 ))
12157 .await;
12158
12159 assert_eq!(response["error"], Value::Null, "{response:#?}");
12160 let methods = response["result"]["methods"]
12161 .as_array()
12162 .ok_or("capabilities methods should be an array")?;
12163 for method in [
12164 "mobkit/status_identity",
12165 "mobkit/inspect_identity",
12166 "mobkit/respawn",
12167 "mobkit/reset",
12168 "mobkit/delete_identity",
12169 ] {
12170 assert!(
12171 methods.iter().any(|candidate| candidate == method),
12172 "identity runtime capabilities should advertise {method}: {methods:#?}"
12173 );
12174 }
12175 for method in crate::rpc::MOBPACK_AUTHORING_METHODS {
12176 assert!(
12177 !methods.iter().any(|candidate| candidate == method),
12178 "console runtime capabilities must not advertise flow-editor method {method}: {methods:#?}"
12179 );
12180 }
12181
12182 let mobpack_response = Box::pin(handle_console_runtime_rpc(
12183 &runtime,
12184 None,
12185 None,
12186 None,
12187 None,
12188 None,
12189 Some(identity_runtime),
12190 None,
12191 None,
12192 rpc_request("mobkit/mobpacks/schema"),
12193 true,
12194 ))
12195 .await;
12196 assert_eq!(
12197 mobpack_response["error"]["code"],
12198 json!(-32601),
12199 "console runtime RPC must not handle flow-editor authoring methods: {mobpack_response:#?}"
12200 );
12201
12202 let _ = runtime.handle().stop().await;
12203 Ok(())
12204 }
12205
12206 #[tokio::test]
12207 async fn console_runtime_capabilities_advertise_agent_memory_with_read_only_gating()
12208 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12209 let (_temp_dir, runtime) =
12210 build_empty_console_test_runtime("console-agent-memory-capabilities").await?;
12211 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12212 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12213 lease_provider: Arc::new(LocalLeaseProvider::new()),
12214 runtime_instance_id: "console-agent-memory-capabilities".to_string(),
12215 has_runtime_store: true,
12216 durability_policy: DurabilityPolicy::SyncWriteThrough,
12217 bridge: None,
12218 default_timeout: None,
12219 }));
12220 let memory_dir = tempfile::tempdir()?;
12221 let memory_store = Arc::new(MarkdownAgentMemoryStore::open(memory_dir.path())?);
12222 identity_runtime
12223 .set_agent_memory(Some(AgentMemoryRuntimeInjector::new(
12224 memory_store,
12225 AgentMemoryConfig::default(),
12226 )))
12227 .await;
12228
12229 let response = Box::pin(handle_console_runtime_rpc(
12230 &runtime,
12231 None,
12232 None,
12233 None,
12234 None,
12235 None,
12236 Some(identity_runtime.clone()),
12237 None,
12238 None,
12239 rpc_request("mobkit/capabilities"),
12240 true,
12241 ))
12242 .await;
12243 assert_eq!(response["error"], Value::Null, "{response:#?}");
12244 let methods = response["result"]["methods"]
12245 .as_array()
12246 .ok_or("capabilities methods should be an array")?;
12247 for method in [
12248 "mobkit/agent_memory/recall",
12249 "mobkit/agent_memory/remember",
12250 "mobkit/agent_memory/forget",
12251 ] {
12252 assert!(
12253 methods.iter().any(|candidate| candidate == method),
12254 "authenticated capabilities should advertise {method}: {methods:#?}"
12255 );
12256 }
12257
12258 let read_only_response = Box::pin(handle_console_runtime_rpc(
12259 &runtime,
12260 None,
12261 None,
12262 None,
12263 None,
12264 None,
12265 Some(identity_runtime),
12266 None,
12267 None,
12268 rpc_request("mobkit/capabilities"),
12269 false,
12270 ))
12271 .await;
12272 assert_eq!(
12273 read_only_response["error"],
12274 Value::Null,
12275 "{read_only_response:#?}"
12276 );
12277 let read_only_methods = read_only_response["result"]["methods"]
12278 .as_array()
12279 .ok_or("read-only capabilities methods should be an array")?;
12280 assert!(
12281 read_only_methods
12282 .iter()
12283 .any(|candidate| candidate == "mobkit/agent_memory/recall"),
12284 "read-only capabilities should keep recall: {read_only_methods:#?}"
12285 );
12286 for method in ["mobkit/agent_memory/remember", "mobkit/agent_memory/forget"] {
12287 assert!(
12288 !read_only_methods
12289 .iter()
12290 .any(|candidate| candidate == method),
12291 "read-only capabilities must omit {method}: {read_only_methods:#?}"
12292 );
12293 }
12294
12295 let _ = runtime.handle().stop().await;
12296 Ok(())
12297 }
12298
12299 #[tokio::test]
12300 async fn console_runtime_agent_memory_methods_round_trip()
12301 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12302 let (_temp_dir, runtime) =
12303 build_empty_console_test_runtime("console-agent-memory-round-trip").await?;
12304 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12305 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12306 lease_provider: Arc::new(LocalLeaseProvider::new()),
12307 runtime_instance_id: "console-agent-memory-round-trip".to_string(),
12308 has_runtime_store: true,
12309 durability_policy: DurabilityPolicy::SyncWriteThrough,
12310 bridge: None,
12311 default_timeout: None,
12312 }));
12313 let identity = AgentIdentity::parse("identity:memory-console")?;
12314 identity_runtime
12315 .register(
12316 DurableAgentSpec {
12317 identity: identity.clone(),
12318 profile: ProfileName::from("default"),
12319 addressability: AgentAddressability::Addressable,
12320 display_name: None,
12321 labels: BTreeMap::new(),
12322 context: None,
12323 additional_instructions: Vec::new(),
12324 initial_message: None,
12325 runtime_mode_override: None,
12326 backend: None,
12327 binding: None,
12328 },
12329 IdentityLifecycleState::Active,
12330 None,
12331 None,
12332 )
12333 .await;
12334 let memory_dir = tempfile::tempdir()?;
12335 let memory_store = Arc::new(MarkdownAgentMemoryStore::open(memory_dir.path())?);
12336 identity_runtime
12337 .set_agent_memory(Some(AgentMemoryRuntimeInjector::new(
12338 memory_store,
12339 AgentMemoryConfig::default(),
12340 )))
12341 .await;
12342
12343 let remember = 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(
12354 "mobkit/agent_memory/remember",
12355 json!({
12356 "identity": "identity:memory-console",
12357 "title": "Console memory token",
12358 "body": "The console memory token is CONSOLE-MEM-17.",
12359 "tags": ["console", "memory"]
12360 }),
12361 ),
12362 true,
12363 ))
12364 .await;
12365 assert_eq!(remember["error"], Value::Null, "{remember:#?}");
12366 let memory_id = remember["result"]["memory_id"]
12367 .as_str()
12368 .ok_or("remember result should include memory_id")?
12369 .to_string();
12370
12371 let recall = Box::pin(handle_console_runtime_rpc(
12372 &runtime,
12373 None,
12374 None,
12375 None,
12376 None,
12377 None,
12378 Some(identity_runtime.clone()),
12379 None,
12380 None,
12381 rpc_request_with_params(
12382 "mobkit/agent_memory/recall",
12383 json!({
12384 "identity": "identity:memory-console",
12385 "selection": "contextual",
12386 "query_text": "Where is CONSOLE-MEM-17?",
12387 "query_terms": ["CONSOLE-MEM-17"]
12388 }),
12389 ),
12390 false,
12391 ))
12392 .await;
12393 assert_eq!(recall["error"], Value::Null, "{recall:#?}");
12394 assert_eq!(
12395 recall["result"]["records"]
12396 .as_array()
12397 .map(std::vec::Vec::len),
12398 Some(1)
12399 );
12400 assert_eq!(
12401 recall["result"]["records"][0]["memory_id"],
12402 json!(memory_id)
12403 );
12404
12405 let read_only_remember = Box::pin(handle_console_runtime_rpc(
12406 &runtime,
12407 None,
12408 None,
12409 None,
12410 None,
12411 None,
12412 Some(identity_runtime.clone()),
12413 None,
12414 None,
12415 rpc_request_with_params(
12416 "mobkit/agent_memory/remember",
12417 json!({
12418 "identity": "identity:memory-console",
12419 "title": "Denied",
12420 "body": "Denied"
12421 }),
12422 ),
12423 false,
12424 ))
12425 .await;
12426 assert_eq!(
12427 read_only_remember["error"]["data"]["kind"],
12428 json!("read_only"),
12429 "{read_only_remember:#?}"
12430 );
12431
12432 let access_controller = AccessController::new(crate::access::AccessControlConfig {
12433 enabled: true,
12434 admins: vec!["admin@example.test".to_string()],
12435 ..crate::access::AccessControlConfig::default()
12436 })?;
12437 let denied_view = access_controller.view_for_subject(Some("viewer@example.test"));
12438 let denied_recall = Box::pin(handle_console_runtime_rpc_with_visibility(
12439 &runtime,
12440 None,
12441 None,
12442 None,
12443 None,
12444 None,
12445 Some(identity_runtime.clone()),
12446 None,
12447 None,
12448 &AllowAllConsoleVisibilityPolicy,
12449 rpc_request_with_params(
12450 "mobkit/agent_memory/recall",
12451 json!({
12452 "identity": "identity:memory-console",
12453 "selection": "always"
12454 }),
12455 ),
12456 true,
12457 false,
12458 None,
12459 Some(&access_controller),
12460 Some(&denied_view),
12461 None,
12462 None,
12463 None,
12464 None,
12465 ))
12466 .await;
12467 assert_eq!(
12468 denied_recall["error"]["data"]["kind"],
12469 json!("access_denied"),
12470 "{denied_recall:#?}"
12471 );
12472 assert_eq!(
12475 denied_recall["error"]["data"]["action"],
12476 json!(ACTION_AGENT_MEMORY_READ),
12477 "{denied_recall:#?}"
12478 );
12479
12480 let send_only_controller = AccessController::new(crate::access::AccessControlConfig {
12481 enabled: true,
12482 admins: vec!["admin@example.test".to_string()],
12483 rules: vec![crate::access::AccessRule {
12484 id: "send-only-memory-console".to_string(),
12485 subjects: vec!["sender@example.test".to_string()],
12486 actions: vec![ACTION_AGENT_SEND.to_string()],
12487 agents: vec!["identity:memory-console".to_string()],
12488 ..crate::access::AccessRule::default()
12489 }],
12490 ..crate::access::AccessControlConfig::default()
12491 })?;
12492 let send_only_view = send_only_controller.view_for_subject(Some("sender@example.test"));
12493 let send_only_remember = Box::pin(handle_console_runtime_rpc_with_visibility(
12494 &runtime,
12495 None,
12496 None,
12497 None,
12498 None,
12499 None,
12500 Some(identity_runtime.clone()),
12501 None,
12502 None,
12503 &AllowAllConsoleVisibilityPolicy,
12504 rpc_request_with_params(
12505 "mobkit/agent_memory/remember",
12506 json!({
12507 "identity": "identity:memory-console",
12508 "title": "Send-only denied",
12509 "body": "Send-only users must not persist durable memory."
12510 }),
12511 ),
12512 true,
12513 false,
12514 None,
12515 Some(&send_only_controller),
12516 Some(&send_only_view),
12517 None,
12518 None,
12519 None,
12520 None,
12521 ))
12522 .await;
12523 assert_eq!(
12524 send_only_remember["error"]["data"]["kind"],
12525 json!("access_denied"),
12526 "{send_only_remember:#?}"
12527 );
12528 assert_eq!(
12529 send_only_remember["error"]["data"]["action"],
12530 json!(ACTION_AGENT_MEMORY_WRITE),
12531 "{send_only_remember:#?}"
12532 );
12533
12534 let send_only_forget = Box::pin(handle_console_runtime_rpc_with_visibility(
12535 &runtime,
12536 None,
12537 None,
12538 None,
12539 None,
12540 None,
12541 Some(identity_runtime.clone()),
12542 None,
12543 None,
12544 &AllowAllConsoleVisibilityPolicy,
12545 rpc_request_with_params(
12546 "mobkit/agent_memory/forget",
12547 json!({
12548 "identity": "identity:memory-console",
12549 "memory_id": memory_id.clone()
12550 }),
12551 ),
12552 true,
12553 false,
12554 None,
12555 Some(&send_only_controller),
12556 Some(&send_only_view),
12557 None,
12558 None,
12559 None,
12560 None,
12561 ))
12562 .await;
12563 assert_eq!(
12564 send_only_forget["error"]["data"]["kind"],
12565 json!("access_denied"),
12566 "{send_only_forget:#?}"
12567 );
12568 assert_eq!(
12569 send_only_forget["error"]["data"]["action"],
12570 json!(ACTION_AGENT_MEMORY_DELETE),
12571 "{send_only_forget:#?}"
12572 );
12573
12574 let wildcard_allow_exact_deny_controller =
12575 AccessController::new(crate::access::AccessControlConfig {
12576 enabled: true,
12577 admins: vec!["admin@example.test".to_string()],
12578 rules: vec![
12579 crate::access::AccessRule {
12580 id: "deny-canonical-memory-console".to_string(),
12581 effect: crate::access::AccessEffect::Deny,
12582 subjects: vec!["wildcard@example.test".to_string()],
12583 actions: vec![
12584 ACTION_AGENT_VIEW.to_string(),
12585 ACTION_AGENT_MEMORY_WRITE.to_string(),
12586 ACTION_AGENT_MEMORY_DELETE.to_string(),
12587 ],
12588 agents: vec!["identity:memory-console".to_string()],
12589 ..crate::access::AccessRule::default()
12590 },
12591 crate::access::AccessRule {
12592 id: "allow-wildcard-memory-console".to_string(),
12593 subjects: vec!["wildcard@example.test".to_string()],
12594 actions: vec![
12595 ACTION_AGENT_VIEW.to_string(),
12596 ACTION_AGENT_MEMORY_WRITE.to_string(),
12597 ACTION_AGENT_MEMORY_DELETE.to_string(),
12598 ],
12599 agents: vec!["*".to_string()],
12600 ..crate::access::AccessRule::default()
12601 },
12602 ],
12603 ..crate::access::AccessControlConfig::default()
12604 })?;
12605 let wildcard_allow_exact_deny_view =
12606 wildcard_allow_exact_deny_controller.view_for_subject(Some("wildcard@example.test"));
12607
12608 let whitespace_denied_remember = Box::pin(handle_console_runtime_rpc_with_visibility(
12609 &runtime,
12610 None,
12611 None,
12612 None,
12613 None,
12614 None,
12615 Some(identity_runtime.clone()),
12616 None,
12617 None,
12618 &AllowAllConsoleVisibilityPolicy,
12619 rpc_request_with_params(
12620 "mobkit/agent_memory/remember",
12621 json!({
12622 "identity": " identity:memory-console ",
12623 "title": "Whitespace denied",
12624 "body": "Whitespace-wrapped identities must be authorized canonically."
12625 }),
12626 ),
12627 true,
12628 false,
12629 None,
12630 Some(&wildcard_allow_exact_deny_controller),
12631 Some(&wildcard_allow_exact_deny_view),
12632 None,
12633 None,
12634 None,
12635 None,
12636 ))
12637 .await;
12638 assert_eq!(
12639 whitespace_denied_remember["error"]["data"]["kind"],
12640 json!("access_denied"),
12641 "{whitespace_denied_remember:#?}"
12642 );
12643 assert_eq!(
12644 whitespace_denied_remember["error"]["data"]["action"],
12645 json!(ACTION_AGENT_MEMORY_WRITE),
12646 "{whitespace_denied_remember:#?}"
12647 );
12648
12649 let whitespace_denied_recall = Box::pin(handle_console_runtime_rpc_with_visibility(
12650 &runtime,
12651 None,
12652 None,
12653 None,
12654 None,
12655 None,
12656 Some(identity_runtime.clone()),
12657 None,
12658 None,
12659 &AllowAllConsoleVisibilityPolicy,
12660 rpc_request_with_params(
12661 "mobkit/agent_memory/recall",
12662 json!({
12663 "identity": " identity:memory-console ",
12664 "selection": "always"
12665 }),
12666 ),
12667 true,
12668 false,
12669 None,
12670 Some(&wildcard_allow_exact_deny_controller),
12671 Some(&wildcard_allow_exact_deny_view),
12672 None,
12673 None,
12674 None,
12675 None,
12676 ))
12677 .await;
12678 assert_eq!(
12679 whitespace_denied_recall["error"]["data"]["kind"],
12680 json!("access_denied"),
12681 "{whitespace_denied_recall:#?}"
12682 );
12683 assert_eq!(
12686 whitespace_denied_recall["error"]["data"]["action"],
12687 json!(ACTION_AGENT_MEMORY_READ),
12688 "{whitespace_denied_recall:#?}"
12689 );
12690
12691 let whitespace_denied_forget = Box::pin(handle_console_runtime_rpc_with_visibility(
12692 &runtime,
12693 None,
12694 None,
12695 None,
12696 None,
12697 None,
12698 Some(identity_runtime.clone()),
12699 None,
12700 None,
12701 &AllowAllConsoleVisibilityPolicy,
12702 rpc_request_with_params(
12703 "mobkit/agent_memory/forget",
12704 json!({
12705 "identity": " identity:memory-console ",
12706 "memory_id": memory_id.clone()
12707 }),
12708 ),
12709 true,
12710 false,
12711 None,
12712 Some(&wildcard_allow_exact_deny_controller),
12713 Some(&wildcard_allow_exact_deny_view),
12714 None,
12715 None,
12716 None,
12717 None,
12718 ))
12719 .await;
12720 assert_eq!(
12721 whitespace_denied_forget["error"]["data"]["kind"],
12722 json!("access_denied"),
12723 "{whitespace_denied_forget:#?}"
12724 );
12725 assert_eq!(
12726 whitespace_denied_forget["error"]["data"]["action"],
12727 json!(ACTION_AGENT_MEMORY_DELETE),
12728 "{whitespace_denied_forget:#?}"
12729 );
12730
12731 let forget = Box::pin(handle_console_runtime_rpc(
12732 &runtime,
12733 None,
12734 None,
12735 None,
12736 None,
12737 None,
12738 Some(identity_runtime.clone()),
12739 None,
12740 None,
12741 rpc_request_with_params(
12742 "mobkit/agent_memory/forget",
12743 json!({
12744 "identity": "identity:memory-console",
12745 "memory_id": memory_id
12746 }),
12747 ),
12748 true,
12749 ))
12750 .await;
12751 assert_eq!(forget["error"], Value::Null, "{forget:#?}");
12752 assert_eq!(forget["result"]["deleted"], json!(true));
12753
12754 let after_forget = Box::pin(handle_console_runtime_rpc(
12755 &runtime,
12756 None,
12757 None,
12758 None,
12759 None,
12760 None,
12761 Some(identity_runtime),
12762 None,
12763 None,
12764 rpc_request_with_params(
12765 "mobkit/agent_memory/recall",
12766 json!({
12767 "identity": "identity:memory-console",
12768 "selection": "always"
12769 }),
12770 ),
12771 false,
12772 ))
12773 .await;
12774 assert_eq!(after_forget["error"], Value::Null, "{after_forget:#?}");
12775 assert_eq!(
12776 after_forget["result"]["records"]
12777 .as_array()
12778 .map(std::vec::Vec::len),
12779 Some(0)
12780 );
12781
12782 let _ = runtime.handle().stop().await;
12783 Ok(())
12784 }
12785
12786 #[tokio::test]
12787 async fn console_runtime_capabilities_advertise_respawn_and_reset_without_identity_runtime()
12788 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12789 let (_temp_dir, runtime) =
12790 build_empty_console_test_runtime("console-plain-lifecycle-capabilities").await?;
12791
12792 let response = Box::pin(handle_console_runtime_rpc(
12796 &runtime,
12797 None,
12798 None,
12799 None,
12800 None,
12801 None,
12802 None,
12803 None,
12804 None,
12805 rpc_request("mobkit/capabilities"),
12806 true,
12807 ))
12808 .await;
12809 assert_eq!(response["error"], Value::Null, "{response:#?}");
12810 let methods = response["result"]["methods"]
12811 .as_array()
12812 .ok_or("capabilities methods should be an array")?;
12813 for method in ["mobkit/retire", "mobkit/respawn", "mobkit/reset"] {
12814 assert!(
12815 methods.iter().any(|candidate| candidate == method),
12816 "plain runtime capabilities should advertise {method}: {methods:#?}"
12817 );
12818 }
12819 assert!(
12820 !methods
12821 .iter()
12822 .any(|candidate| candidate == "mobkit/delete_identity"),
12823 "delete_identity requires an identity runtime: {methods:#?}"
12824 );
12825
12826 let read_only_response = Box::pin(handle_console_runtime_rpc(
12829 &runtime,
12830 None,
12831 None,
12832 None,
12833 None,
12834 None,
12835 None,
12836 None,
12837 None,
12838 rpc_request("mobkit/capabilities"),
12839 false,
12840 ))
12841 .await;
12842 assert_eq!(
12843 read_only_response["error"],
12844 Value::Null,
12845 "{read_only_response:#?}"
12846 );
12847 let read_only_methods = read_only_response["result"]["methods"]
12848 .as_array()
12849 .ok_or("capabilities methods should be an array")?;
12850 for method in ["mobkit/retire", "mobkit/respawn", "mobkit/reset"] {
12851 assert!(
12852 !read_only_methods
12853 .iter()
12854 .any(|candidate| candidate == method),
12855 "unauthenticated capabilities must omit {method}: {read_only_methods:#?}"
12856 );
12857 }
12858
12859 let _ = runtime.handle().stop().await;
12860 Ok(())
12861 }
12862
12863 #[tokio::test]
12864 async fn console_aggregator_rpc_does_not_expose_flow_editor_authoring_methods() {
12865 let response = Box::pin(handle_console_aggregator_rpc(
12866 None,
12867 rpc_request("mobkit/capabilities"),
12868 true,
12869 false,
12870 None,
12871 None,
12872 ))
12873 .await;
12874
12875 assert_eq!(response["error"], Value::Null, "{response:#?}");
12876 let methods = response["result"]["methods"]
12877 .as_array()
12878 .expect("capabilities methods should be an array");
12879 for method in crate::rpc::MOBPACK_AUTHORING_METHODS {
12880 assert!(
12881 !methods.iter().any(|candidate| candidate == method),
12882 "console aggregator capabilities must not advertise flow-editor method {method}: {methods:#?}"
12883 );
12884 }
12885
12886 let mobpack_response = Box::pin(handle_console_aggregator_rpc(
12887 None,
12888 rpc_request("mobkit/mobpacks/schema"),
12889 true,
12890 false,
12891 None,
12892 None,
12893 ))
12894 .await;
12895 assert_eq!(
12896 mobpack_response["error"]["code"],
12897 json!(-32601),
12898 "console aggregator RPC must not handle flow-editor authoring methods: {mobpack_response:#?}"
12899 );
12900 }
12901
12902 #[tokio::test]
12903 async fn console_runtime_identity_reads_reject_stale_runtime_aliases()
12904 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
12905 let (_temp_dir, runtime) =
12906 build_empty_console_test_runtime("console-identity-stale-read-alias").await?;
12907 let mut labels = BTreeMap::new();
12908 labels.insert("agent_identity".to_string(), "review:singleton".to_string());
12909 runtime
12910 .handle()
12911 .spawn_spec(
12912 SpawnMemberSpec::from_wire(
12913 "worker".to_string(),
12914 crate::member_comms_id::mob_member_id_str("rt:review:singleton:0").into_owned(),
12917 Some("You are the stale Review Agent.".into()),
12918 None,
12919 None,
12920 )
12921 .with_labels(labels),
12922 )
12923 .await?;
12924
12925 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
12926 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
12927 lease_provider: Arc::new(LocalLeaseProvider::new()),
12928 runtime_instance_id: "console-identity-stale-read-alias".to_string(),
12929 has_runtime_store: true,
12930 durability_policy: DurabilityPolicy::SyncWriteThrough,
12931 bridge: None,
12932 default_timeout: None,
12933 }));
12934 let identity = AgentIdentity::parse("review:singleton")?;
12935 let record = ContinuityRecord {
12936 identity: identity.clone(),
12937 agent_runtime_id: AgentRuntimeId::parse("rt:review:singleton:1")?,
12938 session_id: meerkat_core::types::SessionId::new(),
12939 generation: ContinuityGeneration::new(1),
12940 checkpoint_version: CheckpointVersion::new(0),
12941 };
12942 identity_runtime
12943 .register(
12944 DurableAgentSpec {
12945 identity: identity.clone(),
12946 profile: ProfileName::from("worker"),
12947 addressability: AgentAddressability::Addressable,
12948 display_name: None,
12949 labels: BTreeMap::new(),
12950 context: None,
12951 additional_instructions: Vec::new(),
12952 initial_message: None,
12953 runtime_mode_override: None,
12954 backend: None,
12955 binding: None,
12956 },
12957 IdentityLifecycleState::Active,
12958 Some(record),
12959 Some(LeaseGrant {
12960 identity,
12961 fencing_token: FencingToken::new(7),
12962 ttl: Duration::from_mins(1),
12963 }),
12964 )
12965 .await;
12966
12967 for requested_identity in ["rt:review:singleton:0", "review:singleton"] {
12968 for method in ["mobkit/status_identity", "mobkit/inspect_identity"] {
12969 let response = Box::pin(handle_console_runtime_rpc(
12970 &runtime,
12971 None,
12972 None,
12973 None,
12974 None,
12975 None,
12976 Some(identity_runtime.clone()),
12977 None,
12978 None,
12979 rpc_request_with_params(method, json!({ "identity": requested_identity })),
12980 true,
12981 ))
12982 .await;
12983 assert_ne!(
12984 response["error"],
12985 Value::Null,
12986 "{method} must reject stale alias for {requested_identity}"
12987 );
12988 let message = response["error"]["message"].as_str().unwrap_or_default();
12989 assert!(
12990 message.contains(
12991 "identity runtime binding for review:singleton points at rt:review:singleton:1"
12992 ),
12993 "unexpected stale-alias message for {method}/{requested_identity}: {message}"
12994 );
12995 assert_eq!(
12996 response["error"]["data"]["kind"],
12997 json!("stale_identity_runtime_binding")
12998 );
12999 assert_eq!(
13000 response["error"]["data"]["registered_runtime_member_id"],
13001 json!("rt:review:singleton:1")
13002 );
13003 assert_eq!(
13004 response["error"]["data"]["live_runtime_member_id"],
13005 json!("rt:review:singleton:0")
13006 );
13007 }
13008 }
13009
13010 let list_response = Box::pin(handle_console_runtime_rpc(
13011 &runtime,
13012 None,
13013 None,
13014 None,
13015 None,
13016 None,
13017 Some(identity_runtime.clone()),
13018 None,
13019 None,
13020 rpc_request("mobkit/list_members"),
13021 true,
13022 ))
13023 .await;
13024 let listed = list_response["result"].as_array().expect("list members");
13025 assert!(
13026 listed
13027 .iter()
13028 .all(|entry| entry["agent_identity"] != json!("rt:review:singleton:0")),
13029 "list_members must filter stale runtime aliases: {list_response:#?}"
13030 );
13031
13032 let find_response = Box::pin(handle_console_runtime_rpc(
13033 &runtime,
13034 None,
13035 None,
13036 None,
13037 None,
13038 None,
13039 Some(identity_runtime.clone()),
13040 None,
13041 None,
13042 rpc_request_with_params(
13043 "mobkit/find_members",
13044 json!({ "label_key": "agent_identity", "label_value": "review:singleton" }),
13045 ),
13046 true,
13047 ))
13048 .await;
13049 let found = find_response["result"].as_array().expect("find members");
13050 assert!(
13051 found
13052 .iter()
13053 .all(|entry| entry["agent_identity"] != json!("rt:review:singleton:0")),
13054 "find_members must filter stale runtime aliases: {find_response:#?}"
13055 );
13056
13057 for method in [
13058 "mobkit/get_member",
13059 "mobkit/member_status",
13060 "mobkit/identity/resolved_tools",
13061 "mobkit/retire_member",
13062 "mobkit/respawn_member",
13063 "mobkit/force_cancel_member",
13064 ] {
13065 let param_name = if method == "mobkit/identity/resolved_tools" {
13066 "identity"
13067 } else {
13068 "member_id"
13069 };
13070 let response = Box::pin(handle_console_runtime_rpc(
13071 &runtime,
13072 None,
13073 None,
13074 None,
13075 None,
13076 None,
13077 Some(identity_runtime.clone()),
13078 None,
13079 None,
13080 rpc_request_with_params(method, json!({ param_name: "rt:review:singleton:0" })),
13081 true,
13082 ))
13083 .await;
13084 assert_ne!(
13085 response["error"],
13086 Value::Null,
13087 "{method} must reject stale runtime alias"
13088 );
13089 assert_eq!(
13090 response["error"]["data"]["kind"],
13091 json!("stale_identity_runtime_binding")
13092 );
13093 assert_eq!(
13094 response["error"]["data"]["live_runtime_member_id"],
13095 json!("rt:review:singleton:0")
13096 );
13097 }
13098
13099 let (_temp_dir_without_stale, runtime_without_stale) =
13100 build_empty_console_test_runtime("console-identity-no-live-stale-alias").await?;
13101
13102 for method in [
13103 "mobkit/status_identity",
13104 "mobkit/inspect_identity",
13105 "mobkit/retire",
13106 "mobkit/respawn",
13107 "mobkit/reset",
13108 ] {
13109 let response = Box::pin(handle_console_runtime_rpc(
13110 &runtime_without_stale,
13111 None,
13112 None,
13113 None,
13114 Some(ConsoleEventStore::new()),
13115 None,
13116 Some(identity_runtime.clone()),
13117 None,
13118 None,
13119 rpc_request_with_params(method, json!({ "identity": "rt:review:singleton:0" })),
13120 true,
13121 ))
13122 .await;
13123 assert_ne!(
13124 response["error"],
13125 Value::Null,
13126 "{method} must reject stale synthetic runtime alias"
13127 );
13128 assert!(
13129 response["error"]["message"]
13130 .as_str()
13131 .unwrap_or_default()
13132 .contains("identity not found: rt:review:singleton:0"),
13133 "unexpected no-live stale-alias response for {method}: {response:#?}"
13134 );
13135 }
13136 let _ = runtime_without_stale.handle().stop().await;
13137
13138 let _ = runtime.handle().stop().await;
13139 Ok(())
13140 }
13141
13142 #[tokio::test]
13143 async fn console_peer_info_uses_current_identity_generation_when_stale_row_remains()
13144 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13145 let (_temp_dir, runtime) =
13146 build_empty_console_test_runtime("console-current-generation-peer-info").await?;
13147 let durable_identity = "review:peer-info-current";
13148 let stale_alias = "rt:review:peer-info-current:0";
13149 let current_alias = "rt:review:peer-info-current:1";
13150 for runtime_alias in [stale_alias, current_alias] {
13151 runtime
13152 .handle()
13153 .spawn_spec(
13154 SpawnMemberSpec::from_wire(
13155 "worker".to_string(),
13156 crate::member_comms_id::mob_member_id_str(runtime_alias).into_owned(),
13157 None,
13158 None,
13159 None,
13160 )
13161 .with_labels(BTreeMap::from([(
13162 "agent_identity".to_string(),
13163 durable_identity.to_string(),
13164 )])),
13165 )
13166 .await?;
13167 }
13168
13169 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
13170 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
13171 lease_provider: Arc::new(LocalLeaseProvider::new()),
13172 runtime_instance_id: "console-current-generation-peer-info".to_string(),
13173 has_runtime_store: true,
13174 durability_policy: DurabilityPolicy::SyncWriteThrough,
13175 bridge: None,
13176 default_timeout: None,
13177 }));
13178 let identity = AgentIdentity::parse(durable_identity)?;
13179 identity_runtime
13180 .register(
13181 DurableAgentSpec {
13182 identity: identity.clone(),
13183 profile: ProfileName::from("worker"),
13184 addressability: AgentAddressability::Addressable,
13185 display_name: None,
13186 labels: BTreeMap::new(),
13187 context: None,
13188 additional_instructions: Vec::new(),
13189 initial_message: None,
13190 runtime_mode_override: None,
13191 backend: None,
13192 binding: None,
13193 },
13194 IdentityLifecycleState::Active,
13195 Some(ContinuityRecord {
13196 identity,
13197 agent_runtime_id: AgentRuntimeId::parse(current_alias)?,
13198 session_id: meerkat_core::types::SessionId::new(),
13199 generation: ContinuityGeneration::new(1),
13200 checkpoint_version: CheckpointVersion::new(0),
13201 }),
13202 None,
13203 )
13204 .await;
13205
13206 let response = Box::pin(handle_console_runtime_rpc(
13207 &runtime,
13208 None,
13209 None,
13210 None,
13211 None,
13212 None,
13213 Some(identity_runtime),
13214 None,
13215 None,
13216 rpc_request_with_params(
13217 "mobkit/cross_mob/peer_info",
13218 json!({"member_id": durable_identity}),
13219 ),
13220 true,
13221 ))
13222 .await;
13223 assert_eq!(response["error"], Value::Null, "{response:#?}");
13224 let comms_name = response["result"]["comms_name"]
13225 .as_str()
13226 .ok_or("peer info must return comms_name")?;
13227 assert!(
13228 comms_name.ends_with(crate::member_comms_id::mob_member_id_str(current_alias).as_ref()),
13229 "console peer info selected the stale generation: {response:#?}"
13230 );
13231
13232 let ambiguous = Box::pin(handle_console_runtime_rpc(
13233 &runtime,
13234 None,
13235 None,
13236 None,
13237 None,
13238 None,
13239 None,
13240 None,
13241 None,
13242 rpc_request_with_params(
13243 "mobkit/cross_mob/peer_info",
13244 json!({"member_id": durable_identity}),
13245 ),
13246 true,
13247 ))
13248 .await;
13249 assert!(
13250 ambiguous["error"]["message"]
13251 .as_str()
13252 .is_some_and(|message| message.contains("ambiguous durable member alias")),
13253 "classic fallback must fail closed when both reset generations remain: {ambiguous:#?}"
13254 );
13255
13256 let _ = runtime.handle().stop().await;
13257 Ok(())
13258 }
13259
13260 #[tokio::test]
13261 async fn aggregator_live_snapshot_projects_identity_first_topology_peers()
13262 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13263 let (_temp_dir, mob_runtime) =
13264 build_empty_console_test_runtime("identity-topology-snapshot-test").await?;
13265 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
13266 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
13267 lease_provider: Arc::new(LocalLeaseProvider::new()),
13268 runtime_instance_id: "console-topology-snapshot-test".to_string(),
13269 has_runtime_store: true,
13270 durability_policy: DurabilityPolicy::SyncWriteThrough,
13271 bridge: None,
13272 default_timeout: None,
13273 }));
13274
13275 for name in ["agent:alpha", "agent:beta"] {
13276 let identity = AgentIdentity::parse(name)?;
13277 let record = ContinuityRecord {
13278 identity: identity.clone(),
13279 agent_runtime_id: AgentRuntimeId::parse(&format!("rt:{name}:0"))?,
13280 session_id: meerkat_core::types::SessionId::new(),
13281 generation: ContinuityGeneration::new(0),
13282 checkpoint_version: CheckpointVersion::new(0),
13283 };
13284 identity_runtime
13285 .register(
13286 DurableAgentSpec {
13287 identity: identity.clone(),
13288 profile: ProfileName::from("default"),
13289 addressability: AgentAddressability::Addressable,
13290 display_name: None,
13291 labels: BTreeMap::new(),
13292 context: None,
13293 additional_instructions: Vec::new(),
13294 initial_message: None,
13295 runtime_mode_override: None,
13296 backend: None,
13297 binding: None,
13298 },
13299 IdentityLifecycleState::Active,
13300 Some(record),
13301 Some(LeaseGrant {
13302 identity,
13303 fencing_token: FencingToken::new(7),
13304 ttl: Duration::from_mins(1),
13305 }),
13306 )
13307 .await;
13308 }
13309 identity_runtime
13310 .set_desired_peer_edges(vec![ManagedPeerEdge::new(
13311 AgentIdentity::parse("agent:alpha")?,
13312 AgentIdentity::parse("agent:beta")?,
13313 )?])
13314 .await;
13315
13316 let aggregator = MobKitConsoleAggregator::in_memory();
13317 aggregator.register_runtime_handles_with_policy(
13318 "identity-first",
13319 "",
13320 mob_runtime.clone(),
13321 Some(identity_runtime),
13322 ConsoleEventStore::new(),
13323 Arc::new(AllowAllConsoleVisibilityPolicy),
13324 );
13325
13326 let snapshot = build_aggregator_live_snapshot(&aggregator, &[]).await?;
13327 let alpha = snapshot
13328 .members
13329 .iter()
13330 .find(|member| member.agent_identity == "agent:alpha")
13331 .ok_or("agent:alpha missing from live snapshot")?;
13332 assert_eq!(alpha.wired_to, vec!["agent:beta".to_string()]);
13333
13334 let _ = mob_runtime.handle().stop().await;
13335 Ok(())
13336 }
13337
13338 #[tokio::test]
13339 async fn identity_first_console_send_reserves_timeline_and_uses_identity_runtime()
13340 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13341 let (_temp_dir, mob_runtime) =
13342 build_empty_console_test_runtime("identity-send-runtime-key-test").await?;
13343 let identity = AgentIdentity::parse("agent:console")?;
13344 let record = ContinuityRecord {
13345 identity: identity.clone(),
13346 agent_runtime_id: AgentRuntimeId::parse("rt:agent:console:0")?,
13347 session_id: meerkat_core::types::SessionId::new(),
13348 generation: ContinuityGeneration::new(0),
13349 checkpoint_version: CheckpointVersion::new(0),
13350 };
13351 let runtime = IdentityRuntime::new(IdentityRuntimeConfig {
13352 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
13353 lease_provider: Arc::new(LocalLeaseProvider::new()),
13354 runtime_instance_id: "console-test".to_string(),
13355 has_runtime_store: true,
13356 durability_policy: DurabilityPolicy::SyncWriteThrough,
13357 bridge: None,
13358 default_timeout: None,
13359 });
13360 runtime
13361 .register(
13362 DurableAgentSpec {
13363 identity: identity.clone(),
13364 profile: ProfileName::from("default"),
13365 addressability: AgentAddressability::Addressable,
13366 display_name: None,
13367 labels: BTreeMap::new(),
13368 context: None,
13369 additional_instructions: Vec::new(),
13370 initial_message: None,
13371 runtime_mode_override: None,
13372 backend: None,
13373 binding: None,
13374 },
13375 IdentityLifecycleState::Active,
13376 Some(record.clone()),
13377 Some(LeaseGrant {
13378 identity: identity.clone(),
13379 fencing_token: FencingToken::new(7),
13380 ttl: Duration::from_mins(1),
13381 }),
13382 )
13383 .await;
13384
13385 let aggregator = MobKitConsoleAggregator::in_memory();
13386 let events = ConsoleEventStore::new();
13387 let runtime = Arc::new(runtime);
13388 aggregator.register_runtime_handles_with_policy(
13389 "default",
13390 "",
13391 mob_runtime.clone(),
13392 Some(runtime.clone()),
13393 events.clone(),
13394 Arc::new(AllowAllConsoleVisibilityPolicy),
13395 );
13396 let accepted = console_send_identity_first(
13397 &aggregator,
13398 runtime.clone(),
13399 Some(&mob_runtime),
13400 Some(&events),
13401 crate::console_aggregator::ConsoleSendRequest {
13402 identity: identity.as_str().to_string(),
13403 content: serde_json::to_value(meerkat_core::ContentInput::Text(
13404 "hello".to_string(),
13405 ))?,
13406 origin: "test".to_string(),
13407 idempotency_key: "idem-1".to_string(),
13408 handling_mode: None,
13409 },
13410 )
13411 .await?;
13412
13413 assert_eq!(accepted.identity, identity.as_str());
13414 assert_eq!(accepted.status, ConsoleFrameStatus::Accepted);
13415 assert_eq!(accepted.session_id, Some(record.session_id.to_string()));
13416
13417 let page = aggregator
13418 .query_timeline(ConsoleTimelineQuery {
13419 identity: Some(identity.as_str().to_string()),
13420 ..ConsoleTimelineQuery::default()
13421 })
13422 .await?;
13423 assert_eq!(page.frames.len(), 1);
13424 assert_eq!(page.frames[0].runtime_key, "default");
13425 assert_eq!(page.frames[0].status, ConsoleFrameStatus::Accepted);
13426 assert_eq!(
13427 page.frames[0].session_id,
13428 Some(record.session_id.to_string())
13429 );
13430 let _ = mob_runtime.handle().stop().await;
13431 Ok(())
13432 }
13433
13434 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
13435 async fn identity_first_console_send_falls_back_to_member_only_spawned_worker()
13436 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13437 let (_temp_dir, mob_runtime) =
13438 build_empty_console_test_runtime("identity-send-member-only-test").await?;
13439 let identity_runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
13440 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
13441 lease_provider: Arc::new(LocalLeaseProvider::new()),
13442 runtime_instance_id: "console-member-only-send-test".to_string(),
13443 has_runtime_store: true,
13444 durability_policy: DurabilityPolicy::SyncWriteThrough,
13445 bridge: None,
13446 default_timeout: None,
13447 }));
13448
13449 let aggregator = MobKitConsoleAggregator::in_memory();
13450 let events = ConsoleEventStore::new();
13451 aggregator.register_runtime_handles_with_policy(
13452 "identity-first",
13453 "",
13454 mob_runtime.clone(),
13455 Some(identity_runtime.clone()),
13456 events.clone(),
13457 Arc::new(AllowAllConsoleVisibilityPolicy),
13458 );
13459
13460 mob_runtime
13461 .handle()
13462 .spawn_spec(SpawnMemberSpec::from_wire(
13463 "worker".to_string(),
13464 crate::member_comms_id::mob_member_id_str("agent:member-only").into_owned(),
13467 Some("You are a member-only spawned worker.".into()),
13468 None,
13469 None,
13470 ))
13471 .await?;
13472
13473 let accepted = console_send_with_identity_first_fallback(
13474 &aggregator,
13475 identity_runtime,
13476 Some(&mob_runtime),
13477 Some(&events),
13478 crate::console_aggregator::ConsoleSendRequest {
13479 identity: "agent:member-only".to_string(),
13480 content: serde_json::to_value(meerkat_core::ContentInput::Text(
13481 "hello spawned worker".to_string(),
13482 ))?,
13483 origin: "test".to_string(),
13484 idempotency_key: "member-only-idem-1".to_string(),
13485 handling_mode: None,
13486 },
13487 )
13488 .await?;
13489
13490 assert_eq!(accepted.identity, "agent:member-only");
13491 assert!(accepted.session_id.is_some());
13492
13493 let page = aggregator
13494 .query_timeline(ConsoleTimelineQuery {
13495 identity: Some("agent:member-only".to_string()),
13496 ..ConsoleTimelineQuery::default()
13497 })
13498 .await?;
13499 assert!(
13500 page.frames.iter().any(|frame| frame.kind == "user_input"),
13501 "fallback send should persist a user input frame for the member-only worker: {page:#?}"
13502 );
13503
13504 let _ = mob_runtime.handle().stop().await;
13505 Ok(())
13506 }
13507
13508 #[tokio::test]
13509 async fn identity_first_console_send_returns_before_bridge_delivery_completes()
13510 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13511 let identity = AgentIdentity::parse("agent:slow-console")?;
13512 let record = ContinuityRecord {
13513 identity: identity.clone(),
13514 agent_runtime_id: AgentRuntimeId::parse("rt:agent:slow-console:0")?,
13515 session_id: meerkat_core::types::SessionId::new(),
13516 generation: ContinuityGeneration::new(0),
13517 checkpoint_version: CheckpointVersion::new(0),
13518 };
13519 let deliver_calls = Arc::new(AtomicUsize::new(0));
13520 let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
13521 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
13522 lease_provider: Arc::new(LocalLeaseProvider::new()),
13523 runtime_instance_id: "console-slow-send-test".to_string(),
13524 has_runtime_store: true,
13525 durability_policy: DurabilityPolicy::SyncWriteThrough,
13526 bridge: Some(Arc::new(BlockingIdentityBridge {
13527 deliver_calls: deliver_calls.clone(),
13528 })),
13529 default_timeout: None,
13530 }));
13531 runtime
13532 .register(
13533 DurableAgentSpec {
13534 identity: identity.clone(),
13535 profile: ProfileName::from("default"),
13536 addressability: AgentAddressability::Addressable,
13537 display_name: None,
13538 labels: BTreeMap::new(),
13539 context: None,
13540 additional_instructions: Vec::new(),
13541 initial_message: None,
13542 runtime_mode_override: None,
13543 backend: None,
13544 binding: None,
13545 },
13546 IdentityLifecycleState::Active,
13547 Some(record.clone()),
13548 Some(LeaseGrant {
13549 identity: identity.clone(),
13550 fencing_token: FencingToken::new(7),
13551 ttl: Duration::from_mins(1),
13552 }),
13553 )
13554 .await;
13555
13556 let aggregator = MobKitConsoleAggregator::in_memory();
13557 let accepted = match tokio::time::timeout(
13558 Duration::from_millis(100),
13559 console_send_identity_first(
13560 &aggregator,
13561 runtime,
13562 None,
13563 None,
13564 crate::console_aggregator::ConsoleSendRequest {
13565 identity: identity.as_str().to_string(),
13566 content: serde_json::to_value(meerkat_core::ContentInput::Text(
13567 "hello slow bridge".to_string(),
13568 ))?,
13569 origin: "test".to_string(),
13570 idempotency_key: "idem-slow-bridge".to_string(),
13571 handling_mode: None,
13572 },
13573 ),
13574 )
13575 .await
13576 {
13577 Ok(Ok(accepted)) => accepted,
13578 Ok(Err(err)) => return Err(format!("send should be accepted: {err}").into()),
13579 Err(err) => {
13580 return Err(
13581 format!("console send should not wait for bridge delivery: {err}").into(),
13582 );
13583 }
13584 };
13585
13586 assert_eq!(accepted.status, ConsoleFrameStatus::Accepted);
13587 if tokio::time::timeout(Duration::from_millis(100), async {
13588 while deliver_calls.load(Ordering::SeqCst) == 0 {
13589 tokio::time::sleep(Duration::from_millis(5)).await;
13590 }
13591 })
13592 .await
13593 .is_err()
13594 {
13595 return Err("delivery should be spawned in the background".into());
13596 }
13597 Ok(())
13598 }
13599
13600 #[tokio::test]
13601 async fn identity_first_console_steer_waits_for_bridge_delivery()
13602 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13603 let identity = AgentIdentity::parse("agent:slow-steer-console")?;
13604 let record = ContinuityRecord {
13605 identity: identity.clone(),
13606 agent_runtime_id: AgentRuntimeId::parse("rt:agent:slow-steer-console:0")?,
13607 session_id: meerkat_core::types::SessionId::new(),
13608 generation: ContinuityGeneration::new(0),
13609 checkpoint_version: CheckpointVersion::new(0),
13610 };
13611 let deliver_calls = Arc::new(AtomicUsize::new(0));
13612 let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
13613 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
13614 lease_provider: Arc::new(LocalLeaseProvider::new()),
13615 runtime_instance_id: "console-slow-steer-send-test".to_string(),
13616 has_runtime_store: true,
13617 durability_policy: DurabilityPolicy::SyncWriteThrough,
13618 bridge: Some(Arc::new(BlockingIdentityBridge {
13619 deliver_calls: deliver_calls.clone(),
13620 })),
13621 default_timeout: None,
13622 }));
13623 runtime
13624 .register(
13625 DurableAgentSpec {
13626 identity: identity.clone(),
13627 profile: ProfileName::from("default"),
13628 addressability: AgentAddressability::Addressable,
13629 display_name: None,
13630 labels: BTreeMap::new(),
13631 context: None,
13632 additional_instructions: Vec::new(),
13633 initial_message: None,
13634 runtime_mode_override: None,
13635 backend: None,
13636 binding: None,
13637 },
13638 IdentityLifecycleState::Active,
13639 Some(record),
13640 Some(LeaseGrant {
13641 identity: identity.clone(),
13642 fencing_token: FencingToken::new(7),
13643 ttl: Duration::from_mins(1),
13644 }),
13645 )
13646 .await;
13647
13648 let aggregator = MobKitConsoleAggregator::in_memory();
13649 let result = tokio::time::timeout(
13650 Duration::from_millis(100),
13651 console_send_identity_first(
13652 &aggregator,
13653 runtime,
13654 None,
13655 None,
13656 crate::console_aggregator::ConsoleSendRequest {
13657 identity: identity.as_str().to_string(),
13658 content: serde_json::to_value(meerkat_core::ContentInput::Text(
13659 "hello slow steer bridge".to_string(),
13660 ))?,
13661 origin: "test".to_string(),
13662 idempotency_key: "idem-slow-steer-bridge".to_string(),
13663 handling_mode: Some("steer".to_string()),
13664 },
13665 ),
13666 )
13667 .await;
13668
13669 if result.is_ok() {
13670 return Err("steer send must wait for bridge delivery admission".into());
13671 }
13672 assert_eq!(
13673 deliver_calls.load(Ordering::SeqCst),
13674 1,
13675 "steer delivery should have reached the bridge before the console response waits"
13676 );
13677 Ok(())
13678 }
13679
13680 #[tokio::test]
13681 async fn identity_first_console_send_forwards_handling_mode()
13682 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13683 let identity = AgentIdentity::parse("agent:mode-console")?;
13684 let record = ContinuityRecord {
13685 identity: identity.clone(),
13686 agent_runtime_id: AgentRuntimeId::parse("rt:agent:mode-console:0")?,
13687 session_id: meerkat_core::types::SessionId::new(),
13688 generation: ContinuityGeneration::new(0),
13689 checkpoint_version: CheckpointVersion::new(0),
13690 };
13691 let handling_modes = Arc::new(Mutex::new(Vec::new()));
13692 let runtime = Arc::new(IdentityRuntime::new(IdentityRuntimeConfig {
13693 continuity_store: Arc::new(LocalContinuityStore::in_memory()?),
13694 lease_provider: Arc::new(LocalLeaseProvider::new()),
13695 runtime_instance_id: "console-mode-send-test".to_string(),
13696 has_runtime_store: true,
13697 durability_policy: DurabilityPolicy::SyncWriteThrough,
13698 bridge: Some(Arc::new(RecordingIdentityBridge {
13699 session_id: record.session_id.clone(),
13700 handling_modes: handling_modes.clone(),
13701 })),
13702 default_timeout: None,
13703 }));
13704 runtime
13705 .register(
13706 DurableAgentSpec {
13707 identity: identity.clone(),
13708 profile: ProfileName::from("default"),
13709 addressability: AgentAddressability::Addressable,
13710 display_name: None,
13711 labels: BTreeMap::new(),
13712 context: None,
13713 additional_instructions: Vec::new(),
13714 initial_message: None,
13715 runtime_mode_override: None,
13716 backend: None,
13717 binding: None,
13718 },
13719 IdentityLifecycleState::Active,
13720 Some(record),
13721 Some(LeaseGrant {
13722 identity: identity.clone(),
13723 fencing_token: FencingToken::new(7),
13724 ttl: Duration::from_mins(1),
13725 }),
13726 )
13727 .await;
13728
13729 let aggregator = MobKitConsoleAggregator::in_memory();
13730 let accepted = console_send_identity_first(
13731 &aggregator,
13732 runtime,
13733 None,
13734 None,
13735 crate::console_aggregator::ConsoleSendRequest {
13736 identity: identity.as_str().to_string(),
13737 content: serde_json::to_value(meerkat_core::ContentInput::Text(
13738 "hello steer bridge".to_string(),
13739 ))?,
13740 origin: "test".to_string(),
13741 idempotency_key: "idem-steer-bridge".to_string(),
13742 handling_mode: Some("steer".to_string()),
13743 },
13744 )
13745 .await?;
13746
13747 if tokio::time::timeout(Duration::from_millis(100), async {
13748 loop {
13749 if handling_modes
13750 .lock()
13751 .map(|modes| modes.contains(&HandlingMode::Steer))
13752 .unwrap_or(false)
13753 {
13754 break;
13755 }
13756 tokio::time::sleep(Duration::from_millis(5)).await;
13757 }
13758 })
13759 .await
13760 .is_err()
13761 {
13762 return Err("identity-first console send should forward steer mode".into());
13763 }
13764
13765 let terminal_frame = tokio::time::timeout(Duration::from_millis(500), async {
13766 loop {
13767 let page = aggregator
13768 .query_timeline(ConsoleTimelineQuery {
13769 identity: Some(identity.as_str().to_string()),
13770 ..ConsoleTimelineQuery::default()
13771 })
13772 .await
13773 .map_err(|err| format!("query timeline: {err}"))?;
13774 if page.frames.iter().any(|frame| {
13775 frame.kind == "interaction_complete"
13776 && frame.interaction_id.as_deref() == Some(accepted.interaction_id.as_str())
13777 && frame.payload.get("reason").and_then(Value::as_str)
13778 == Some("steer_delivered")
13779 }) {
13780 return Ok::<(), String>(());
13781 }
13782 tokio::time::sleep(Duration::from_millis(5)).await;
13783 }
13784 })
13785 .await;
13786 match terminal_frame {
13787 Ok(Ok(())) => {}
13788 Ok(Err(err)) => return Err(err.into()),
13789 Err(_) => {
13790 return Err(
13791 "identity-first steer send should terminalize its console interaction".into(),
13792 );
13793 }
13794 }
13795 Ok(())
13796 }
13797
13798 #[test]
13799 fn multipart_body_limit_covers_configured_image_limit() {
13800 const _: () = assert!(MAX_MULTIPART_BODY_BYTES > MAX_MULTIPART_IMAGE_BYTES);
13801 const _: () = assert!(MAX_MULTIPART_BODY_BYTES > 2 * 1024 * 1024);
13802 }
13803
13804 #[tokio::test]
13817 async fn cold_cache_waiter_resumes_when_refresh_lock_drops()
13818 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13819 use std::sync::atomic::Ordering;
13820 use tokio::time::Duration;
13821
13822 let model = ConsoleSnapshotReadModel::default();
13823 let guard = model
13824 .refresh_lock
13825 .clone()
13826 .try_lock_owned()
13827 .map_err(|_| "refresh_lock unexpectedly contended at test start")?;
13828
13829 let model_for_waiter = model.clone();
13830 let waiter = tokio::spawn(async move {
13831 if model_for_waiter
13834 .primed
13835 .load(std::sync::atomic::Ordering::Acquire)
13836 {
13837 return;
13838 }
13839 let _wait_guard = model_for_waiter.refresh_lock.clone().lock_owned().await;
13840 assert!(
13843 model_for_waiter
13844 .primed
13845 .load(std::sync::atomic::Ordering::Acquire),
13846 "waiter acquired lock but primed is still false"
13847 );
13848 });
13849
13850 tokio::time::sleep(Duration::from_millis(20)).await;
13852
13853 model.primed.store(true, Ordering::Release);
13856 drop(guard);
13857
13858 let result = tokio::time::timeout(Duration::from_secs(1), waiter).await;
13859 assert!(
13860 result.is_ok(),
13861 "waiter should resume once the refresh lock drops"
13862 );
13863 Ok(())
13864 }
13865
13866 #[tokio::test]
13871 async fn snapshot_skips_refresh_lock_when_already_primed()
13872 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13873 use std::sync::atomic::Ordering;
13874 use tokio::time::Duration;
13875
13876 let model = ConsoleSnapshotReadModel::default();
13877 model.primed.store(true, Ordering::Release);
13878 let _guard = model
13880 .refresh_lock
13881 .clone()
13882 .try_lock_owned()
13883 .map_err(|_| "refresh_lock unexpectedly contended at test start")?;
13884
13885 let snap_fast_path = async {
13891 assert!(
13892 model.primed.load(Ordering::Acquire),
13893 "primed precondition for hot-cache path"
13894 );
13895 };
13896 let result = tokio::time::timeout(Duration::from_millis(100), snap_fast_path).await;
13897 assert!(result.is_ok(), "hot-cache snapshot path should not block");
13898 Ok(())
13899 }
13900
13901 #[tokio::test]
13902 async fn console_aggregator_reset_all_rpc_rejects_destructive_retire_all_semantics()
13903 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
13904 let (_temp_dir, runtime) =
13905 build_empty_console_test_runtime("console-reset-fresh-identity-cache").await?;
13906 let aggregator = MobKitConsoleAggregator::in_memory();
13907 aggregator.register_runtime_handles_with_policy(
13908 "runtime-reset",
13909 "reset",
13910 runtime.clone(),
13911 None,
13912 ConsoleEventStore::new(),
13913 Arc::new(AllowAllConsoleVisibilityPolicy),
13914 );
13915 let primed_empty = aggregator.list_identities().await?;
13916 assert!(
13917 primed_empty.is_empty(),
13918 "test precondition: identity cache should be primed empty before late spawn"
13919 );
13920
13921 runtime
13922 .handle()
13923 .spawn_spec(SpawnMemberSpec::from_wire(
13924 "worker".to_string(),
13925 "agent-reset".to_string(),
13926 Some("You are agent-reset.".into()),
13927 None,
13928 None,
13929 ))
13930 .await?;
13931
13932 let response = Box::pin(handle_console_aggregator_rpc(
13933 Some(aggregator),
13934 rpc_request("mobkit/reset_all"),
13935 true,
13936 false,
13937 None,
13938 None,
13939 ))
13940 .await;
13941
13942 assert_eq!(response["result"], Value::Null);
13943 assert_eq!(
13944 response["error"]["data"]["kind"],
13945 json!("unsupported_reset_all_surface")
13946 );
13947 assert!(
13948 runtime
13949 .handle()
13950 .get_member(&meerkat_mob::ids::AgentIdentity::from("agent-reset"))
13951 .await
13952 .ok()
13953 .flatten()
13954 .is_some(),
13955 "aggregator reset_all must not retire live members while reporting unsupported"
13956 );
13957 let _ = runtime.handle().stop().await;
13958 Ok(())
13959 }
13960
13961 #[test]
13962 fn timeline_stream_cursor_filter_uses_numeric_console_sequence() {
13963 assert!(cursor_is_after(
13964 &ConsoleCursor::from("console:10"),
13965 &ConsoleCursor::from("console:9")
13966 ));
13967 assert!(!cursor_is_after(
13968 &ConsoleCursor::from("console:9"),
13969 &ConsoleCursor::from("console:10")
13970 ));
13971 }
13972
13973 #[test]
13974 fn console_live_snapshot_dedupes_repeated_delegate_identities() {
13975 let mut members = vec![
13976 ConsoleMember {
13977 agent_identity: "incident-commander".to_string(),
13978 role: "commander".to_string(),
13979 state: "active".to_string(),
13980 model_capabilities: Default::default(),
13981 runtime_mode: None,
13982 session_id: None,
13983 wired_to: Vec::new(),
13984 labels: BTreeMap::new(),
13985 progress: None,
13986 },
13987 ConsoleMember {
13988 agent_identity: "qa-child".to_string(),
13989 role: "delegate".to_string(),
13990 state: "active".to_string(),
13991 model_capabilities: Default::default(),
13992 runtime_mode: None,
13993 session_id: Some("first".to_string()),
13994 wired_to: vec!["qa-parent".to_string()],
13995 labels: BTreeMap::from([(
13996 "delegate_host_identity".to_string(),
13997 "qa-parent".to_string(),
13998 )]),
13999 progress: None,
14000 },
14001 ConsoleMember {
14002 agent_identity: "qa-child".to_string(),
14003 role: "delegate".to_string(),
14004 state: "active".to_string(),
14005 model_capabilities: Default::default(),
14006 runtime_mode: None,
14007 session_id: Some("second".to_string()),
14008 wired_to: vec!["qa-parent".to_string()],
14009 labels: BTreeMap::from([(
14010 "delegate_host_identity".to_string(),
14011 "qa-parent".to_string(),
14012 )]),
14013 progress: None,
14014 },
14015 ];
14016
14017 dedupe_console_members_by_identity(&mut members);
14018
14019 assert_eq!(
14020 members
14021 .iter()
14022 .map(|member| member.agent_identity.as_str())
14023 .collect::<Vec<_>>(),
14024 vec!["incident-commander", "qa-child"]
14025 );
14026 assert_eq!(members[1].session_id.as_deref(), Some("first"));
14027 }
14028
14029 #[test]
14030 fn console_visibility_policy_hides_implicit_delegate_members_from_snapshot() {
14031 let mut snapshot = ConsoleLiveSnapshot::new(
14032 Some("runtime".to_string()),
14033 true,
14034 vec!["incident-commander".to_string(), "qa-child".to_string()],
14035 vec![
14036 ConsoleAgentLiveSnapshot {
14037 agent_id: "incident-commander".to_string(),
14038 member_id: "incident-commander".to_string(),
14039 label: "Incident Commander".to_string(),
14040 kind: "meerkat".to_string(),
14041 identity: Some("incident-commander".to_string()),
14042 role: Some("commander".to_string()),
14043 state: Some("active".to_string()),
14044 session_id: None,
14045 model_capabilities: Default::default(),
14046 response_phase: None,
14047 watched: None,
14048 alert_level: None,
14049 degraded: None,
14050 degraded_reason: None,
14051 },
14052 ConsoleAgentLiveSnapshot {
14053 agent_id: "qa-child".to_string(),
14054 member_id: "qa-child".to_string(),
14055 label: "QA Child".to_string(),
14056 kind: "meerkat".to_string(),
14057 identity: Some("qa-child".to_string()),
14058 role: Some("delegate".to_string()),
14059 state: Some("active".to_string()),
14060 session_id: Some("delegate-session".to_string()),
14061 model_capabilities: Default::default(),
14062 response_phase: None,
14063 watched: None,
14064 alert_level: None,
14065 degraded: None,
14066 degraded_reason: None,
14067 },
14068 ],
14069 vec![
14070 ConsoleMember {
14071 agent_identity: "incident-commander".to_string(),
14072 role: "commander".to_string(),
14073 state: "active".to_string(),
14074 model_capabilities: Default::default(),
14075 runtime_mode: None,
14076 session_id: None,
14077 wired_to: Vec::new(),
14078 labels: BTreeMap::new(),
14079 progress: None,
14080 },
14081 ConsoleMember {
14082 agent_identity: "qa-child".to_string(),
14083 role: "delegate".to_string(),
14084 state: "active".to_string(),
14085 model_capabilities: Default::default(),
14086 runtime_mode: None,
14087 session_id: Some("delegate-session".to_string()),
14088 wired_to: vec!["qa-parent".to_string()],
14089 labels: BTreeMap::from([(
14090 "source_mob_id".to_string(),
14091 "implicit-qa-mob".to_string(),
14092 )]),
14093 progress: None,
14094 },
14095 ],
14096 true,
14097 );
14098
14099 apply_console_visibility_policy(
14100 &mut snapshot,
14101 &HideImplicitDelegateMembersConsoleVisibilityPolicy,
14102 );
14103
14104 assert_eq!(
14105 snapshot
14106 .members
14107 .iter()
14108 .map(|member| member.agent_identity.as_str())
14109 .collect::<Vec<_>>(),
14110 vec!["incident-commander"]
14111 );
14112 assert_eq!(
14113 snapshot
14114 .agents
14115 .iter()
14116 .map(|agent| agent.agent_id.as_str())
14117 .collect::<Vec<_>>(),
14118 vec!["incident-commander"]
14119 );
14120 assert_eq!(snapshot.loaded_modules, vec!["incident-commander"]);
14121 }
14122
14123 #[tokio::test]
14124 async fn live_snapshot_member_projection_uses_roster_profile_capabilities()
14125 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
14126 let temp_dir = tempfile::tempdir()?;
14127 let session_path = temp_dir.path().join("sessions");
14128 std::fs::create_dir_all(&session_path)?;
14129 let factory = AgentFactory::new(&session_path).comms(true);
14130 let session_service = Arc::new(build_ephemeral_service(factory, Config::default(), 16));
14131 let definition = MobDefinition::from_toml(
14132 r#"
14133[mob]
14134id = "console-snapshot-test"
14135
14136[profiles.worker]
14137model = "gpt-5.5"
14138
14139[profiles.worker.tools]
14140comms = true
14141"#,
14142 )?;
14143 let expected = model_capabilities_for_role(&definition, "worker");
14144 let runtime = MobRuntime::bootstrap(
14145 MobBootstrapSpec::new(definition, MobStorage::in_memory(), session_service)
14146 .with_options(MobBootstrapOptions {
14147 allow_ephemeral_sessions: true,
14148 notify_orchestrator_on_resume: true,
14149 default_llm_client: Some(Arc::new(TestClient::default())),
14150 }),
14151 )
14152 .await?;
14153 runtime
14154 .handle()
14155 .spawn_spec(SpawnMemberSpec::from_wire(
14156 "worker".to_string(),
14157 crate::member_comms_id::mob_member_id_str("worker:one").into_owned(),
14160 Some("You are worker one.".into()),
14161 None,
14162 None,
14163 ))
14164 .await?;
14165
14166 let empty_read_model = ConsoleSnapshotReadModelState::default();
14167 let (members, session_owner_by_id) = project_console_members_from_handle(
14168 &runtime.handle(),
14169 None,
14170 None,
14171 &empty_read_model,
14172 &BTreeMap::new(),
14173 )
14174 .await;
14175
14176 assert_eq!(members.len(), 1);
14177 assert_eq!(members[0].model_capabilities, expected);
14178 assert_eq!(members[0].session_id, None);
14179 assert!(session_owner_by_id.is_empty());
14180
14181 let refreshed_read_model = collect_console_snapshot_read_model(&runtime).await;
14182 let (members, session_owner_by_id) = project_console_members_from_handle(
14183 &runtime.handle(),
14184 None,
14185 None,
14186 &refreshed_read_model,
14187 &BTreeMap::new(),
14188 )
14189 .await;
14190 assert_eq!(
14191 members[0].session_id.as_ref(),
14192 session_owner_by_id.keys().next()
14193 );
14194
14195 assert_eq!(
14201 refreshed_read_model.primary_members.len(),
14202 members.len(),
14203 "primary_members cache should hold the same members as live projection"
14204 );
14205 assert_eq!(
14206 refreshed_read_model.primary_members[0].agent_identity,
14207 members[0].agent_identity
14208 );
14209 assert_eq!(
14210 refreshed_read_model.primary_members[0].session_id,
14211 members[0].session_id
14212 );
14213 Ok(())
14214 }
14215
14216 #[tokio::test]
14217 async fn fresh_timeline_snapshot_reads_tail_without_full_log_replay()
14218 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
14219 let aggregator = MobKitConsoleAggregator::in_memory();
14220 for idx in 0..250_000 {
14221 aggregator
14222 .store()
14223 .append_if_absent(NewConsoleFrame {
14224 id: None,
14225 dedupe_key: format!("event-{idx}"),
14226 timestamp_ms: idx,
14227 runtime_key: "runtime-a".to_string(),
14228 identity: "agent-a".to_string(),
14229 conversation_id: Some("agent-a".to_string()),
14230 session_id: None,
14231 kind: "text_delta".to_string(),
14232 status: ConsoleFrameStatus::Completed,
14233 payload: json!({ "delta": idx }),
14234 source: ConsoleFrameSource {
14235 kind: ConsoleFrameSourceKind::ConsoleEvent,
14236 source_cursor: None,
14237 },
14238 source_event_id: Some(format!("event-{idx}")),
14239 interaction_id: None,
14240 turn_id: None,
14241 run_id: None,
14242 parent_frame_id: None,
14243 caused_by_frame_id: None,
14244 })
14245 .await?;
14246 }
14247
14248 let (frames, cursor) = query_timeline_snapshot(
14249 &aggregator,
14250 ConsoleTimelineWindowQuery {
14251 identity: Some("agent-a".to_string()),
14252 after: None,
14253 limit: 200,
14254 ..ConsoleTimelineWindowQuery::default()
14255 },
14256 )
14257 .await?;
14258
14259 assert!(!frames.is_empty());
14260 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(250_000));
14261 assert_eq!(
14262 frames.last().and_then(|frame| frame.cursor.seq()),
14263 Some(250_000)
14264 );
14265 Ok(())
14266 }
14267
14268 #[tokio::test]
14269 async fn fresh_timeline_snapshot_keeps_sparse_identity_frames()
14270 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
14271 let aggregator = MobKitConsoleAggregator::in_memory();
14272 aggregator
14273 .store()
14274 .append_if_absent(NewConsoleFrame {
14275 id: None,
14276 dedupe_key: "sparse-event".to_string(),
14277 timestamp_ms: 1,
14278 runtime_key: "runtime-a".to_string(),
14279 identity: "sparse-agent".to_string(),
14280 conversation_id: Some("sparse-agent".to_string()),
14281 session_id: None,
14282 kind: "text_complete".to_string(),
14283 status: ConsoleFrameStatus::Completed,
14284 payload: json!({ "text": "still visible" }),
14285 source: ConsoleFrameSource {
14286 kind: ConsoleFrameSourceKind::ConsoleEvent,
14287 source_cursor: None,
14288 },
14289 source_event_id: Some("sparse-event".to_string()),
14290 interaction_id: None,
14291 turn_id: None,
14292 run_id: None,
14293 parent_frame_id: None,
14294 caused_by_frame_id: None,
14295 })
14296 .await?;
14297 for idx in 0..25_000 {
14298 aggregator
14299 .store()
14300 .append_if_absent(NewConsoleFrame {
14301 id: None,
14302 dedupe_key: format!("other-event-{idx}"),
14303 timestamp_ms: idx + 2,
14304 runtime_key: "runtime-a".to_string(),
14305 identity: "busy-agent".to_string(),
14306 conversation_id: Some("busy-agent".to_string()),
14307 session_id: None,
14308 kind: "text_delta".to_string(),
14309 status: ConsoleFrameStatus::Completed,
14310 payload: json!({ "delta": idx }),
14311 source: ConsoleFrameSource {
14312 kind: ConsoleFrameSourceKind::ConsoleEvent,
14313 source_cursor: None,
14314 },
14315 source_event_id: Some(format!("other-event-{idx}")),
14316 interaction_id: None,
14317 turn_id: None,
14318 run_id: None,
14319 parent_frame_id: None,
14320 caused_by_frame_id: None,
14321 })
14322 .await?;
14323 }
14324
14325 let (frames, cursor) = query_timeline_snapshot(
14326 &aggregator,
14327 ConsoleTimelineWindowQuery {
14328 identity: Some("sparse-agent".to_string()),
14329 after: None,
14330 limit: 200,
14331 ..ConsoleTimelineWindowQuery::default()
14332 },
14333 )
14334 .await?;
14335
14336 assert_eq!(frames.len(), 1);
14337 assert_eq!(frames[0].identity, "sparse-agent");
14338 assert_eq!(frames[0].payload["text"], json!("still visible"));
14339 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(1));
14340 Ok(())
14341 }
14342
14343 #[tokio::test]
14344 async fn fresh_identity_snapshot_keeps_user_input_anchor_before_noisy_tail()
14345 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
14346 let aggregator = MobKitConsoleAggregator::in_memory();
14347 aggregator
14348 .store()
14349 .append_if_absent(NewConsoleFrame {
14350 id: None,
14351 dedupe_key: "worker-kickoff".to_string(),
14352 timestamp_ms: 1,
14353 runtime_key: "runtime-a".to_string(),
14354 identity: "review-worker-a".to_string(),
14355 conversation_id: Some("review-worker-a".to_string()),
14356 session_id: None,
14357 kind: "user_input".to_string(),
14358 status: ConsoleFrameStatus::Delivered,
14359 payload: json!({
14360 "content": [
14361 {
14362 "type": "text",
14363 "text": "Console chat smoke: review this initiative"
14364 }
14365 ]
14366 }),
14367 source: ConsoleFrameSource {
14368 kind: ConsoleFrameSourceKind::Synthetic,
14369 source_cursor: None,
14370 },
14371 source_event_id: Some("worker-kickoff".to_string()),
14372 interaction_id: Some("kickoff-1".to_string()),
14373 turn_id: None,
14374 run_id: None,
14375 parent_frame_id: None,
14376 caused_by_frame_id: None,
14377 })
14378 .await?;
14379 for idx in 0..1_500 {
14380 aggregator
14381 .store()
14382 .append_if_absent(NewConsoleFrame {
14383 id: None,
14384 dedupe_key: format!("worker-delta-{idx}"),
14385 timestamp_ms: idx + 2,
14386 runtime_key: "runtime-a".to_string(),
14387 identity: "review-worker-a".to_string(),
14388 conversation_id: Some("review-worker-a".to_string()),
14389 session_id: None,
14390 kind: "reasoning_delta".to_string(),
14391 status: ConsoleFrameStatus::Delivered,
14392 payload: json!({ "delta": idx }),
14393 source: ConsoleFrameSource {
14394 kind: ConsoleFrameSourceKind::ConsoleEvent,
14395 source_cursor: None,
14396 },
14397 source_event_id: Some(format!("worker-delta-{idx}")),
14398 interaction_id: Some("kickoff-1".to_string()),
14399 turn_id: None,
14400 run_id: None,
14401 parent_frame_id: None,
14402 caused_by_frame_id: None,
14403 })
14404 .await?;
14405 }
14406
14407 let (frames, cursor) = query_timeline_snapshot(
14408 &aggregator,
14409 ConsoleTimelineWindowQuery {
14410 identity: Some("review-worker-a".to_string()),
14411 after: None,
14412 limit: 200,
14413 ..ConsoleTimelineWindowQuery::default()
14414 },
14415 )
14416 .await?;
14417
14418 assert!(
14419 frames.iter().any(|frame| {
14420 frame.kind == "user_input"
14421 && frame.payload.to_string().contains("Console chat smoke")
14422 }),
14423 "identity chat snapshot must keep the worker kickoff prompt before a noisy tail: {frames:#?}",
14424 );
14425 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(1_501));
14426 assert_eq!(
14427 frames.last().and_then(|frame| frame.cursor.seq()),
14428 Some(1_501)
14429 );
14430 Ok(())
14431 }
14432
14433 #[tokio::test]
14434 async fn timeline_snapshot_drains_since_backlog_across_store_pages()
14435 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
14436 let aggregator = MobKitConsoleAggregator::in_memory();
14437 for idx in 0..2_500 {
14438 aggregator
14439 .store()
14440 .append_if_absent(NewConsoleFrame {
14441 id: None,
14442 dedupe_key: format!("clamp-event-{idx}"),
14443 timestamp_ms: idx,
14444 runtime_key: "runtime-a".to_string(),
14445 identity: "agent-a".to_string(),
14446 conversation_id: Some("agent-a".to_string()),
14447 session_id: None,
14448 kind: "text_delta".to_string(),
14449 status: ConsoleFrameStatus::Completed,
14450 payload: json!({ "delta": idx }),
14451 source: ConsoleFrameSource {
14452 kind: ConsoleFrameSourceKind::ConsoleEvent,
14453 source_cursor: None,
14454 },
14455 source_event_id: Some(format!("clamp-event-{idx}")),
14456 interaction_id: None,
14457 turn_id: None,
14458 run_id: None,
14459 parent_frame_id: None,
14460 caused_by_frame_id: None,
14461 })
14462 .await?;
14463 }
14464
14465 let (frames, cursor) = query_timeline_snapshot(
14466 &aggregator,
14467 ConsoleTimelineWindowQuery {
14468 identity: Some("agent-a".to_string()),
14469 after: Some(ConsoleCursor::from("console:100")),
14470 limit: 5_000,
14471 ..ConsoleTimelineWindowQuery::default()
14472 },
14473 )
14474 .await?;
14475
14476 assert_eq!(frames.len(), 2_400);
14477 assert_eq!(
14478 frames.first().and_then(|frame| frame.cursor.seq()),
14479 Some(101)
14480 );
14481 assert_eq!(
14482 frames.last().and_then(|frame| frame.cursor.seq()),
14483 Some(2_500)
14484 );
14485 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(2_500));
14486 Ok(())
14487 }
14488
14489 #[tokio::test]
14490 async fn timeline_snapshot_drains_since_backlog_beyond_old_page_budget()
14491 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
14492 let aggregator = MobKitConsoleAggregator::in_memory();
14493 for idx in 1..=150 {
14494 aggregator
14495 .store()
14496 .append_if_absent(NewConsoleFrame {
14497 id: None,
14498 dedupe_key: format!("deep-backlog-event-{idx}"),
14499 timestamp_ms: idx,
14500 runtime_key: "runtime-a".to_string(),
14501 identity: "agent-a".to_string(),
14502 conversation_id: Some("agent-a".to_string()),
14503 session_id: None,
14504 kind: "text_delta".to_string(),
14505 status: ConsoleFrameStatus::Completed,
14506 payload: json!({ "delta": idx }),
14507 source: ConsoleFrameSource {
14508 kind: ConsoleFrameSourceKind::ConsoleEvent,
14509 source_cursor: None,
14510 },
14511 source_event_id: Some(format!("deep-backlog-event-{idx}")),
14512 interaction_id: None,
14513 turn_id: None,
14514 run_id: None,
14515 parent_frame_id: None,
14516 caused_by_frame_id: None,
14517 })
14518 .await?;
14519 }
14520
14521 let (frames, cursor) = query_timeline_snapshot(
14522 &aggregator,
14523 ConsoleTimelineWindowQuery {
14524 identity: Some("agent-a".to_string()),
14525 after: Some(ConsoleCursor::from_seq(1)),
14526 limit: 1,
14527 ..ConsoleTimelineWindowQuery::default()
14528 },
14529 )
14530 .await?;
14531
14532 assert_eq!(frames.len(), 149);
14533 assert_eq!(frames.first().and_then(|frame| frame.cursor.seq()), Some(2));
14534 assert_eq!(
14535 frames.last().and_then(|frame| frame.cursor.seq()),
14536 Some(150)
14537 );
14538 assert_eq!(cursor.as_ref().and_then(ConsoleCursor::seq), Some(150));
14539 Ok(())
14540 }
14541
14542 #[tokio::test]
14543 async fn timeline_snapshot_rejects_after_cursor_beyond_store_frontier()
14544 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
14545 let aggregator = MobKitConsoleAggregator::in_memory();
14546 aggregator
14547 .store()
14548 .append_if_absent(NewConsoleFrame {
14549 id: None,
14550 dedupe_key: "stale-frontier-event".to_string(),
14551 timestamp_ms: 1,
14552 runtime_key: "runtime-a".to_string(),
14553 identity: "agent-a".to_string(),
14554 conversation_id: Some("agent-a".to_string()),
14555 session_id: None,
14556 kind: "text_delta".to_string(),
14557 status: ConsoleFrameStatus::Completed,
14558 payload: json!({ "delta": 1 }),
14559 source: ConsoleFrameSource {
14560 kind: ConsoleFrameSourceKind::ConsoleEvent,
14561 source_cursor: None,
14562 },
14563 source_event_id: Some("stale-frontier-event".to_string()),
14564 interaction_id: None,
14565 turn_id: None,
14566 run_id: None,
14567 parent_frame_id: None,
14568 caused_by_frame_id: None,
14569 })
14570 .await?;
14571
14572 let err = match query_timeline_snapshot(
14573 &aggregator,
14574 ConsoleTimelineWindowQuery {
14575 after: Some(ConsoleCursor::from("console:99")),
14576 limit: 200,
14577 ..ConsoleTimelineWindowQuery::default()
14578 },
14579 )
14580 .await
14581 {
14582 Ok(_) => {
14583 return Err(
14584 std::io::Error::other("future cursor must be replay-unavailable").into(),
14585 );
14586 }
14587 Err(err) => err,
14588 };
14589
14590 assert!(
14591 err.to_string()
14592 .contains("beyond the current store frontier"),
14593 "unexpected error: {err}"
14594 );
14595 Ok(())
14596 }
14597
14598 #[tokio::test]
14599 async fn timeline_snapshot_rejects_after_cursor_beyond_empty_store_frontier()
14600 -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
14601 let aggregator = MobKitConsoleAggregator::in_memory();
14602
14603 let err = match query_timeline_snapshot(
14604 &aggregator,
14605 ConsoleTimelineWindowQuery {
14606 after: Some(ConsoleCursor::from("console:99")),
14607 limit: 200,
14608 ..ConsoleTimelineWindowQuery::default()
14609 },
14610 )
14611 .await
14612 {
14613 Ok(_) => {
14614 return Err(std::io::Error::other("empty store future cursor must fail").into());
14615 }
14616 Err(err) => err,
14617 };
14618
14619 assert!(
14620 err.to_string()
14621 .contains("beyond the current store frontier"),
14622 "unexpected error: {err}"
14623 );
14624 Ok(())
14625 }
14626
14627 #[test]
14628 fn timeline_query_prefers_last_event_id_over_url_after_cursor() {
14629 let query = timeline_query_from_http(
14630 ConsoleTimelineHttpQuery {
14631 identity: None,
14632 conversation_id: None,
14633 after: Some("console:100".to_string()),
14634 before: None,
14635 mode: None,
14636 limit: None,
14637 },
14638 Some("console:150".to_string()),
14639 );
14640
14641 assert_eq!(query.after.as_ref().and_then(ConsoleCursor::seq), Some(150));
14642 }
14643
14644 #[test]
14645 fn console_timeline_replay_unavailable_rpc_uses_dedicated_error_code() {
14646 let response = console_timeline_replay_unavailable_response(
14647 json!("rid"),
14648 std::io::Error::other("timeline replay cursor is beyond the current store frontier")
14649 .into(),
14650 Some(&ConsoleCursor::from_seq(100)),
14651 Some(ConsoleCursor::from_seq(42)),
14652 );
14653
14654 assert_eq!(response["error"]["code"], json!(-32013));
14655 assert_eq!(
14656 response["error"]["data"],
14657 json!({
14658 "error": "replay_unavailable",
14659 "stream": "timeline",
14660 "requested_cursor": "console:100",
14661 "latest_cursor": "console:42",
14662 })
14663 );
14664 }
14665
14666 #[tokio::test]
14667 async fn multipart_blob_upload_stores_one_file() -> Result<(), Box<dyn std::error::Error>> {
14668 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14669 let mut files = BTreeMap::new();
14670 files.insert(
14671 "upload-1".to_string(),
14672 MultipartImageUpload {
14673 media_type: "image/png".to_string(),
14674 bytes: Bytes::from_static(b"png-data"),
14675 },
14676 );
14677 let result = externalize_single_image_upload(
14678 &json!({
14679 "upload": {
14680 "type": "image_upload",
14681 "upload_id": "upload-1",
14682 "media_type": "image/png"
14683 }
14684 }),
14685 files,
14686 store.clone(),
14687 )
14688 .await
14689 .map_err(std::io::Error::other)?;
14690
14691 assert_eq!(result["media_type"], json!("image/png"));
14692 assert_eq!(result["size"], json!(8));
14693 let Some(blob_id) = result["blob_id"].as_str() else {
14694 return Err(std::io::Error::other("blob id").into());
14695 };
14696 let payload = store
14697 .get_bytes(&meerkat_core::BlobId::from(blob_id))
14698 .await?;
14699 assert_eq!(payload.data.as_ref(), b"png-data");
14700 Ok(())
14701 }
14702
14703 #[tokio::test]
14704 async fn multipart_blob_upload_accepts_part_name_alias()
14705 -> Result<(), Box<dyn std::error::Error>> {
14706 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14707 let mut files = BTreeMap::new();
14708 files.insert(
14709 "image-field".to_string(),
14710 MultipartImageUpload {
14711 media_type: "image/png".to_string(),
14712 bytes: Bytes::from_static(b"png-data"),
14713 },
14714 );
14715 let result = externalize_single_image_upload(
14716 &json!({
14717 "upload": {
14718 "type": "image_upload",
14719 "part_name": "image-field",
14720 "media_type": "image/png"
14721 }
14722 }),
14723 files,
14724 store,
14725 )
14726 .await
14727 .map_err(std::io::Error::other)?;
14728
14729 assert_eq!(result["media_type"], json!("image/png"));
14730 assert!(
14731 result["blob_id"]
14732 .as_str()
14733 .is_some_and(|value| value.starts_with("sha256:"))
14734 );
14735 Ok(())
14736 }
14737
14738 #[tokio::test]
14739 async fn multipart_blob_upload_rejects_media_mismatch() -> Result<(), Box<dyn std::error::Error>>
14740 {
14741 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14742 let mut files = BTreeMap::new();
14743 files.insert(
14744 "upload-1".to_string(),
14745 MultipartImageUpload {
14746 media_type: "image/jpeg".to_string(),
14747 bytes: Bytes::from_static(b"jpeg-data"),
14748 },
14749 );
14750 let err = match externalize_single_image_upload(
14751 &json!({
14752 "upload": {
14753 "type": "image_upload",
14754 "upload_id": "upload-1",
14755 "media_type": "image/png"
14756 }
14757 }),
14758 files,
14759 store,
14760 )
14761 .await
14762 {
14763 Ok(_) => return Err(std::io::Error::other("media mismatch").into()),
14764 Err(err) => err,
14765 };
14766 assert!(
14767 err.contains("media type mismatch"),
14768 "unexpected error: {err}"
14769 );
14770 Ok(())
14771 }
14772
14773 #[tokio::test]
14774 async fn multipart_blob_upload_rejects_extra_file() -> Result<(), Box<dyn std::error::Error>> {
14775 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14776 let mut files = BTreeMap::new();
14777 for id in ["upload-1", "upload-2"] {
14778 files.insert(
14779 id.to_string(),
14780 MultipartImageUpload {
14781 media_type: "image/png".to_string(),
14782 bytes: Bytes::from_static(b"png"),
14783 },
14784 );
14785 }
14786 let err = match externalize_single_image_upload(
14787 &json!({
14788 "upload": {
14789 "type": "image_upload",
14790 "upload_id": "upload-1",
14791 "media_type": "image/png"
14792 }
14793 }),
14794 files,
14795 store,
14796 )
14797 .await
14798 {
14799 Ok(_) => return Err(std::io::Error::other("one file only").into()),
14800 Err(err) => err,
14801 };
14802 assert!(
14803 err.contains("exactly one file part"),
14804 "unexpected error: {err}"
14805 );
14806 Ok(())
14807 }
14808
14809 #[tokio::test]
14810 async fn multipart_send_replaces_placeholders_and_removes_shadow_message()
14811 -> Result<(), Box<dyn std::error::Error>> {
14812 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14813 let mut files = BTreeMap::new();
14814 files.insert(
14815 "upload-1".to_string(),
14816 MultipartImageUpload {
14817 media_type: "image/webp".to_string(),
14818 bytes: Bytes::from_static(b"webp-data"),
14819 },
14820 );
14821 let mut params = json!({
14822 "member_id": "artist",
14823 "message": "stale shadow text",
14824 "content": [
14825 { "type": "text", "text": "describe" },
14826 {
14827 "type": "image_upload",
14828 "upload_id": "upload-1",
14829 "media_type": "image/webp"
14830 }
14831 ]
14832 });
14833 externalize_image_upload_placeholders(&mut params, files, store)
14834 .await
14835 .map_err(std::io::Error::other)?;
14836
14837 assert!(params.get("message").is_none());
14838 assert_eq!(params["content"][1]["type"], json!("image"));
14839 assert_eq!(params["content"][1]["source"], json!("blob"));
14840 assert_eq!(params["content"][1]["media_type"], json!("image/webp"));
14841 assert!(
14842 params["content"][1]["blob_id"]
14843 .as_str()
14844 .is_some_and(|value| value.starts_with("sha256:"))
14845 );
14846 Ok(())
14847 }
14848
14849 #[tokio::test]
14850 async fn multipart_send_accepts_part_name_placeholder() -> Result<(), Box<dyn std::error::Error>>
14851 {
14852 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14853 let mut files = BTreeMap::new();
14854 files.insert(
14855 "image-field".to_string(),
14856 MultipartImageUpload {
14857 media_type: "image/png".to_string(),
14858 bytes: Bytes::from_static(b"png-data"),
14859 },
14860 );
14861 let mut params = json!({
14862 "member_id": "analyst",
14863 "content": [
14864 { "type": "text", "text": "describe" },
14865 {
14866 "type": "image_upload",
14867 "part_name": "image-field",
14868 "media_type": "image/png"
14869 }
14870 ]
14871 });
14872
14873 externalize_image_upload_placeholders(&mut params, files, store)
14874 .await
14875 .map_err(std::io::Error::other)?;
14876
14877 assert_eq!(params["content"][1]["type"], json!("image"));
14878 assert_eq!(params["content"][1]["source"], json!("blob"));
14879 assert_eq!(params["content"][1]["media_type"], json!("image/png"));
14880 Ok(())
14881 }
14882
14883 #[tokio::test]
14884 async fn multipart_send_rejects_placeholder_without_file()
14885 -> Result<(), Box<dyn std::error::Error>> {
14886 let store: Arc<dyn BinaryBlobStore> = Arc::new(ObjectStoreBlobStore::memory());
14887 let mut params = json!({
14888 "content": [{
14889 "type": "image_upload",
14890 "upload_id": "missing",
14891 "media_type": "image/png"
14892 }]
14893 });
14894 let err = match externalize_image_upload_placeholders(&mut params, BTreeMap::new(), store)
14895 .await
14896 {
14897 Ok(()) => return Err(std::io::Error::other("missing file").into()),
14898 Err(err) => err,
14899 };
14900 assert!(err.contains("missing file part"), "unexpected error: {err}");
14901 Ok(())
14902 }
14903
14904 #[test]
14905 fn generated_runtime_ids_do_not_match_sibling_colon_identities() {
14906 assert!(!member_id_matches_durable_identity(
14907 "rt:review:singleton:0",
14908 "review:singleton"
14909 ));
14910 assert!(!member_id_matches_durable_identity(
14911 "review:singleton:gen1",
14912 "review:singleton"
14913 ));
14914 assert!(!member_id_matches_durable_identity(
14915 "review:singleton:1",
14916 "review:singleton"
14917 ));
14918 assert!(!member_id_matches_durable_identity(
14919 "rt:review:singleton:qa:0",
14920 "review:singleton"
14921 ));
14922 assert!(!member_id_matches_durable_identity(
14923 "review:singleton:qa",
14924 "review:singleton"
14925 ));
14926 assert_eq!(
14927 console_runtime_alias_generation("rt:review:singleton:7", "review:singleton"),
14928 Some(7)
14929 );
14930 assert_eq!(
14931 console_runtime_alias_generation("rt:review:singleton:8", "review:other"),
14932 None
14933 );
14934 }
14935}