Skip to main content

meerkat_mobkit/
http_console.rs

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