Skip to main content

hyperactor/
introspect.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Introspection protocol for hyperactor actors.
10//!
11//! Every actor has a dedicated introspect task that handles
12//! [`IntrospectMessage`] by reading [`InstanceCell`] state directly,
13//! without going through the actor's message loop. This means:
14//!
15//! - Stuck actors can be introspected (the task runs independently).
16//! - Introspection does not perturb observed state (no Heisenberg).
17//! - Live status is reported accurately.
18//!
19//! Infrastructure actors publish domain-specific metadata via
20//! `publish_attrs()`, which the introspect task reads for Entity-view
21//! queries. Non-addressable children (e.g., system procs) are
22//! resolved via a callback registered on [`InstanceCell`].
23//!
24//! Callers navigate topology by fetching an [`IntrospectResult`] and
25//! following its `children` references.
26//!
27//! # Design Invariants
28//!
29//! The introspection subsystem maintains twelve invariants (S1--S12).
30//! Each is documented at the code site that enforces it.
31//!
32//! - **S1.** Introspection must not depend on actor responsiveness --
33//!   a wedged actor can still be introspected (runtime task, not
34//!   actor loop).
35//! - **S2.** Introspection must not perturb observed state -- reading
36//!   `InstanceCell` never sets `last_message_handler` to
37//!   `IntrospectMessage`.
38//! - **S3.** Sender routing is unchanged -- senders target the same
39//!   control `PortId` across processes.
40//! - **S4.** `IntrospectMessage` never produces a `WorkCell` --
41//!   pre-registration via `bind_control_port` gives the introspect
42//!   port its own channel, independent of the actor's work queue.
43//! - **S5.** Replies never use `PanickingMailboxSender` -- the
44//!   introspect task replies via `Mailbox::serialize_and_send_once`.
45//! - **S6.** View semantics are stable -- Actor view uses live
46//!   structural state + supervision children; Entity view uses
47//!   published properties + domain children.
48//! - **S7.** `QueryChild` must work without actor handlers -- system
49//!   procs are resolved via a per-actor callback on `InstanceCell`.
50//! - **S8.** Published properties are constrained -- actors cannot
51//!   publish `Root` or `Error` payloads (only `Host` and `Proc`
52//!   variants).
53//! - **S9.** Port binding is single source of truth -- the introspect
54//!   port is bound exactly once via `bind_handler_port()` in
55//!   `Instance::new()`.
56//! - **S10.** Introspect receiver lifecycle -- created in
57//!   `Instance::new()`, spawned in `start()`, dropped in
58//!   `child_instance()`.
59//! - **S11.** Terminated snapshots do not keep actors resolvable --
60//!   `store_terminated_snapshot` writes to the proc's snapshot map,
61//!   not the instances map. `resolve_actor_ref` checks terminal
62//!   status independently and is unaffected by snapshot storage.
63//! - **S12.** Introspection must not impair actor liveness --
64//!   introspection queries (including DashMap reads for actor
65//!   enumeration) must not cause convoy starvation or scheduling
66//!   delays that stall concurrent actor spawn/stop operations.
67//!
68//! ## Introspection key invariants (IK-*)
69//!
70//! - **IK-1 (metadata completeness):** Every actor-runtime
71//!   introspection key must carry `@meta(INTROSPECT = ...)` with
72//!   non-empty `name` and `desc`.
73//! - **IK-2 (short-name uniqueness):** No two introspection keys may
74//!   share the same `IntrospectAttr.name`. Duplicates would break the
75//!   FQ-to-short HTTP remap and schema output.
76//!
77//! ## Failure introspection invariants (FI-*)
78//!
79//! The FailureInfo presentation type lives in
80//! `hyperactor_mesh::introspect`; these invariants are documented
81//! here because the enforcement sites are in hyperactor (`proc.rs`
82//! `serve()`, `live_actor_payload`).
83//!
84//! - **FI-1 (event-before-status):** All `InstanceCell` state that
85//!   `live_actor_payload` reads must be written BEFORE
86//!   `change_status()` transitions to terminal.
87//! - **FI-2 (write-once):** `InstanceCellState::supervision_event` is
88//!   written at most once per actor lifetime.
89//! - **FI-3 (failure attrs <-> status):** Failure attrs are present
90//!   iff status is `"failed"`.
91//! - **FI-4 (is_propagated <-> root_cause_actor):**
92//!   `failure_is_propagated == true` iff `failure_root_cause_actor !=
93//!   this_actor_id`.
94//! - **FI-5 (is_poisoned <-> failed_actor_count):** `is_poisoned ==
95//!   true` iff `failed_actor_count > 0`.
96//! - **FI-6 (clean stop = no artifacts):** When an actor stops
97//!   cleanly, `supervision_event` is `None`, failure attrs are
98//!   absent, and the actor does not contribute to
99//!   `failed_actor_count`.
100//! - **FI-7 (propagated-stopped-root-cause):** When a failed actor's
101//!   supervision chain bottoms out in a `Stopped` child event,
102//!   structured failure metadata must still name the stopped child as
103//!   `failure_root_cause_actor`.
104//! - **FI-8 (propagation-classification):** `failure_is_propagated`
105//!   is derived from root-cause actor identity; a parent that failed
106//!   due to a child's event must report `failure_is_propagated ==
107//!   true`.
108//!
109//! ## Attrs view invariants (AV-*)
110//!
111//! These govern the typed view layer (`ActorAttrsView`). The full
112//! AV-* / DP-* family is documented in `hyperactor_mesh::introspect`;
113//! the subset relevant to this crate:
114//!
115//! - **AV-1 (view-roundtrip):** For each view V,
116//!   `V::from_attrs(&v.to_attrs()) == Ok(v)`.
117//! - **AV-2 (required-key-strictness):** `from_attrs` fails iff
118//!   required keys for that view are missing.
119//! - **AV-3 (unknown-key-tolerance):** Unknown attrs keys must not
120//!   affect successful decode outcome.
121//!
122//! ## Inbound ordering exposure invariants (IO-*)
123//!
124//! - **IO-1 (inbound-ordering tri-state semantics):**
125//!   `ActorAttrsView::inbound_ordering` carries three meaningful states
126//!   that consumers (DTO, TUI, agents) MUST distinguish:
127//!   * `None` -- no snapshot callback was installed. In current code
128//!     this means structural absence: an `InstanceCellState` not built
129//!     through `Instance::new` (hand-built test fixtures, or any future
130//!     code path that bypasses the constructor). Live actors built via
131//!     `Instance::new` always install Some, and terminated-actor
132//!     payloads -- which still go through `live_actor_payload(&cell)`
133//!     while the cell exists -- inherit that Some.
134//!   * `Some({enabled: false, ...})` -- ordered path exists but reorder
135//!     buffering is disabled; `sessions` is empty regardless of traffic.
136//!     Messages bypass receiver-local sequencing.
137//!   * `Some({enabled: true, ...})` -- buffering active; `sessions`
138//!     is meaningful.
139//!
140//!   `None` is NOT equivalent to `Some({enabled: false, ...})`.
141//! - **IO-2 (inbound-ordering reflects publish-time state):** When
142//!   present, the snapshot is computed at `build_actor_attrs`
143//!   invocation time via the sequenced receiver's snapshot handle.
144//!   `last_released_seq` etc. are point-in-time. Sessions held by a
145//!   concurrent receive show up in `skipped_session_count` (never silently omitted);
146//!   `is_complete()` reports the all-clear.
147//! - **IO-3 (queue-depth and inbound-ordering are independent
148//!   diagnostics, no arithmetic contract):**
149//!   * `ACTOR_QUEUE_DEPTH` (per PD-5a/PD-5b in `proc.rs`): accepted
150//!     handler work not yet dequeued by the actor loop.
151//!   * `INBOUND_ORDERING.sessions[*].buffered_count`: messages held by
152//!     receiver-local sequencing waiting for a seq gap to fill.
153//!
154//!   These are two independent point-in-time diagnostics. No
155//!   arithmetic or ordering relationship between them is part of the
156//!   API contract; the accounting paths are free to change. Consumers
157//!   must not derive one from the other.
158//!
159//! ## Actor-attrs snapshot invariants (AS-*)
160//!
161//! These govern the generic per-actor introspection-attrs snapshot
162//! seam: an actor may install a `Fn() -> Attrs` callback via
163//! `Instance::set_attrs_snapshot`, which the introspect task invokes in
164//! `build_actor_attrs` and merges into the Actor view. The seam is the
165//! runtime-agnostic transport for actor-supplied introspection data
166//! (e.g. a Python actor reporting in-flight handler execution); core
167//! interprets none of it.
168//!
169//! - **AS-1 (snapshot-opacity):** actor-supplied attrs are merged into
170//!   the Actor view verbatim. Core neither interprets nor validates the
171//!   keys -- any key the actor sets is transported as-is. This is what
172//!   keeps the seam runtime-agnostic (no Rust-vs-Python knowledge, no
173//!   "endpoint" concept in core).
174//! - **AS-2 (core-precedence):** on a key collision, the core/runtime
175//!   key wins over the actor-supplied value. This is the Actor-view
176//!   analog of IA-2, which governs the published-attrs path; the two
177//!   sources of actor-supplied keys (published attrs, snapshot seam) are
178//!   distinct, but both yield to core keys.
179//! - **AS-3 (snapshot-non-fatal):** an absent, empty, or panicking
180//!   snapshot degrades to the core-only Actor view. The callback is
181//!   `catch_unwind`-guarded and the merge falls back to the core attrs,
182//!   so a bad snapshot can never empty or invalidate `attrs` (with IA-1,
183//!   IA-5).
184
185use std::fmt;
186use std::str::FromStr;
187use std::time::SystemTime;
188
189use hyperactor_config::Attrs;
190use hyperactor_config::INTROSPECT;
191use hyperactor_config::IntrospectAttr;
192use hyperactor_config::declare_attrs;
193use serde::Deserialize;
194use serde::Serialize;
195use typeuri::Named;
196
197use crate::ActorAddr;
198use crate::Addr;
199use crate::AddrParseError;
200use crate::InstanceCell;
201use crate::OncePortRef;
202use crate::ProcAddr;
203/// Typed reference to an introspectable entity.
204///
205/// This is the generic hyperactor layer — it knows about procs and
206/// actors, not mesh-specific concepts like root or host.
207///
208/// Port references are intentionally excluded — introspection
209/// does not address individual ports.
210#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Named)]
211pub enum IntrospectRef {
212    /// A proc reference.
213    Proc(ProcAddr),
214    /// An actor reference.
215    Actor(ActorAddr),
216}
217hyperactor_config::impl_attrvalue!(IntrospectRef);
218
219/// Error returned when parsing an [`IntrospectRef`].
220#[derive(Debug, thiserror::Error)]
221pub enum IntrospectRefParseError {
222    /// The address text could not be parsed.
223    #[error(transparent)]
224    Addr(#[from] AddrParseError),
225    /// Port references are not introspectable.
226    #[error("port references are not valid introspection references")]
227    PortNotAllowed,
228}
229
230impl fmt::Display for IntrospectRef {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        match self {
233            Self::Proc(id) => fmt::Display::fmt(id, f),
234            Self::Actor(id) => fmt::Display::fmt(id, f),
235        }
236    }
237}
238
239impl FromStr for IntrospectRef {
240    type Err = IntrospectRefParseError;
241
242    fn from_str(s: &str) -> Result<Self, Self::Err> {
243        let r: Addr = s.parse()?;
244        match r {
245            Addr::Proc(id) => Ok(Self::Proc(id)),
246            Addr::Actor(id) => Ok(Self::Actor(id)),
247            Addr::Port(_) => Err(IntrospectRefParseError::PortNotAllowed),
248        }
249    }
250}
251
252impl From<ProcAddr> for IntrospectRef {
253    fn from(id: ProcAddr) -> Self {
254        Self::Proc(id)
255    }
256}
257
258impl From<ActorAddr> for IntrospectRef {
259    fn from(id: ActorAddr) -> Self {
260        Self::Actor(id)
261    }
262}
263
264// Introspection attr keys — actor-runtime concepts.
265//
266// These keys are populated by the introspect handler from
267// InstanceCell data. Mesh-topology keys (node_type, addr, num_procs,
268// etc.) are declared in hyperactor_mesh::introspect.
269//
270// Naming convention:
271//
272// - Attr names are node-type-agnostic. The `node_type` attr (from the
273//   mesh layer) identifies what kind of node it is; individual attr
274//   names don't repeat that. So `status`, not `actor_status`.
275// - Related attrs share a prefix to form a group. The `failure_*`
276//   keys decompose failure info into flat attrs — the `failure_`
277//   prefix groups them semantically.
278// - `actor_type` is an exception: the `actor_` prefix disambiguates
279//   it from `node_type` (mesh-layer concept). `actor_type` is the
280//   Rust actor type name; `node_type` is the topology role.
281// - Use real types where possible (e.g. SystemTime for timestamps),
282//   not String. Serialization format is a presentation concern.
283// - Internal key names are fully-qualified by `declare_attrs!`
284//   (module_path + attr constant), e.g.
285//   `hyperactor::introspect::status`.
286// - HTTP/schema public key names come from `@meta(INTROSPECT =
287//   IntrospectAttr { name, desc })`. Keep `name` explicit so API
288//   stability is decoupled from internal refactors.
289//
290// See IK-1 (metadata completeness) and IK-2 (short-name uniqueness)
291// in module doc.
292declare_attrs! {
293    /// Actor lifecycle status: "running", "stopped", "failed".
294    ///
295    /// Together with `STATUS_REASON`, these two attrs replace the
296    /// former `actor_status` prefix protocol (`"stopped:reason"`,
297    /// `"failed:reason"`) with structured fields, eliminating string
298    /// prefix parsing in consumers.
299    @meta(INTROSPECT = IntrospectAttr {
300        name: "status".into(),
301        desc: "Actor lifecycle status: running, stopped, failed".into(),
302    })
303    pub attr STATUS: String;
304
305    /// Reason for stop/failure (absent when running).
306    @meta(INTROSPECT = IntrospectAttr {
307        name: "status_reason".into(),
308        desc: "Reason for stop/failure (absent when running)".into(),
309    })
310    pub attr STATUS_REASON: String;
311
312    /// Fully-qualified actor type name.
313    @meta(INTROSPECT = IntrospectAttr {
314        name: "actor_type".into(),
315        desc: "Fully-qualified actor type name".into(),
316    })
317    pub attr ACTOR_TYPE: String;
318
319    /// Number of messages processed by this actor.
320    @meta(INTROSPECT = IntrospectAttr {
321        name: "messages_processed".into(),
322        desc: "Number of messages processed by this actor".into(),
323    })
324    pub attr MESSAGES_PROCESSED: u64 = 0;
325
326    /// Timestamp when this actor was created.
327    @meta(INTROSPECT = IntrospectAttr {
328        name: "created_at".into(),
329        desc: "Timestamp when this actor was created".into(),
330    })
331    pub attr CREATED_AT: SystemTime;
332
333    /// Name of the last message handler invoked.
334    @meta(INTROSPECT = IntrospectAttr {
335        name: "last_handler".into(),
336        desc: "Name of the last message handler invoked".into(),
337    })
338    pub attr LAST_HANDLER: String;
339
340    /// Total CPU time in message handlers (microseconds).
341    @meta(INTROSPECT = IntrospectAttr {
342        name: "total_processing_time_us".into(),
343        desc: "Total CPU time in message handlers (microseconds)".into(),
344    })
345    pub attr TOTAL_PROCESSING_TIME_US: u64 = 0;
346
347    /// Flight recorder JSON (recent trace events).
348    @meta(INTROSPECT = IntrospectAttr {
349        name: "flight_recorder".into(),
350        desc: "Flight recorder JSON (recent trace events)".into(),
351    })
352    pub attr FLIGHT_RECORDER: String;
353
354    /// Whether this actor is infrastructure/system.
355    @meta(INTROSPECT = IntrospectAttr {
356        name: "is_system".into(),
357        desc: "Whether this actor is infrastructure/system".into(),
358    })
359    pub attr IS_SYSTEM: bool = false;
360
361    /// Child references for tree navigation. Published by
362    /// infrastructure actors (HostMeshAgent, ProcAgent) so the
363    /// Entity view can return children without parsing mesh-layer keys.
364    @meta(INTROSPECT = IntrospectAttr {
365        name: "children".into(),
366        desc: "Child references for tree navigation".into(),
367    })
368    pub attr CHILDREN: Vec<IntrospectRef>;
369
370    /// Machine-readable error code for error nodes.
371    @meta(INTROSPECT = IntrospectAttr {
372        name: "error_code".into(),
373        desc: "Machine-readable error code (e.g. not_found)".into(),
374    })
375    pub attr ERROR_CODE: String;
376
377    /// Human-readable error message for error nodes.
378    @meta(INTROSPECT = IntrospectAttr {
379        name: "error_message".into(),
380        desc: "Human-readable error message".into(),
381    })
382    pub attr ERROR_MESSAGE: String;
383
384    // Failure attrs — decomposition of FailureInfo into flat attrs.
385    //
386    // - **FI-A1 (presence):** failure_* attrs are present iff
387    //   status == "failed"; absent otherwise. (Attr-level restatement
388    //   of FI-3.)
389    // - **FI-A2 (propagation):** failure_is_propagated == true iff
390    //   failure_root_cause_actor != this actor's id. (Attr-level
391    //   restatement of FI-4.)
392    // FI-1, FI-2 (write ordering) are enforced in proc.rs serve()
393    // and are unaffected by the representation change.
394    // FI-5, FI-6 are proc/mesh-level and unaffected.
395
396    /// Failure error message.
397    @meta(INTROSPECT = IntrospectAttr {
398        name: "failure_error_message".into(),
399        desc: "Failure error message".into(),
400    })
401    pub attr FAILURE_ERROR_MESSAGE: String;
402
403    /// Actor that caused the failure (root cause).
404    @meta(INTROSPECT = IntrospectAttr {
405        name: "failure_root_cause_actor".into(),
406        desc: "Actor that caused the failure (root cause)".into(),
407    })
408    pub attr FAILURE_ROOT_CAUSE_ACTOR: ActorAddr;
409
410    /// Name of root cause actor.
411    @meta(INTROSPECT = IntrospectAttr {
412        name: "failure_root_cause_name".into(),
413        desc: "Name of root cause actor".into(),
414    })
415    pub attr FAILURE_ROOT_CAUSE_NAME: String;
416
417    /// Timestamp when failure occurred.
418    @meta(INTROSPECT = IntrospectAttr {
419        name: "failure_occurred_at".into(),
420        desc: "Timestamp when failure occurred".into(),
421    })
422    pub attr FAILURE_OCCURRED_AT: SystemTime;
423
424    /// Whether the failure was propagated from a child.
425    @meta(INTROSPECT = IntrospectAttr {
426        name: "failure_is_propagated".into(),
427        desc: "Whether the failure was propagated from a child".into(),
428    })
429    pub attr FAILURE_IS_PROPAGATED: bool = false;
430
431    /// Stable per-instance identifier (`Uuid::now_v7`) assigned at
432    /// `Instance::new`.
433    @meta(INTROSPECT = IntrospectAttr {
434        name: "instance_id".into(),
435        desc: "Stable per-instance Uuid::now_v7() identity assigned at Instance::new".into(),
436    })
437    pub attr INSTANCE_ID: String;
438
439    /// Accepted handler work not yet dequeued by the actor loop (per
440    /// `proc.rs` PD-5a/PD-5b). Independent of `INBOUND_ORDERING`:
441    /// no arithmetic or ordering relationship between the two is part
442    /// of the API contract. See IO-3.
443    @meta(INTROSPECT = IntrospectAttr {
444        name: "queue_depth".into(),
445        desc: "Accepted handler work not yet dequeued by the actor loop (PD-5a/b). Independent of inbound_ordering; no arithmetic contract -- see IO-3.".into(),
446    })
447    pub attr ACTOR_QUEUE_DEPTH: u64 = 0;
448
449    /// Per-session reorder state from the sequenced receiver snapshot.
450    /// `sessions[*].buffered_count` reports messages held by
451    /// receiver-local sequencing waiting for a seq gap to fill. Independent
452    /// diagnostic from `queue_depth`; no arithmetic contract -- see
453    /// IO-3.
454    ///
455    /// Absence (`None` on `ActorAttrsView`) means structural absence
456    /// (no snapshot callback installed). `Some({enabled: false, ...})`
457    /// means the path exists but buffering is disabled. See IO-1.
458    @meta(INTROSPECT = IntrospectAttr {
459        name: "inbound_ordering".into(),
460        desc: "Per-session reorder-buffer state from receiver-local sequencing. Independent diagnostic from queue_depth; no arithmetic contract -- see IO-3. Absence vs Some({enabled: false}) is meaningful -- see IO-1.".into(),
461    })
462    pub attr INBOUND_ORDERING: crate::ordering::OrderingSnapshot;
463}
464
465// See FI-1 through FI-8 in module doc.
466
467/// Error from decoding an `Attrs` bag into a typed view.
468#[derive(Debug, Clone, PartialEq)]
469pub enum AttrsViewError {
470    /// A required key was absent (and has no default).
471    MissingKey {
472        /// The attr key that was absent.
473        key: &'static str,
474    },
475    /// A cross-field coherence check failed.
476    InvariantViolation {
477        /// Invariant label (e.g. "IA-4").
478        label: &'static str,
479        /// Human-readable description of the violation.
480        detail: String,
481    },
482}
483
484impl fmt::Display for AttrsViewError {
485    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
486        match self {
487            Self::MissingKey { key } => write!(f, "missing required key: {key}"),
488            Self::InvariantViolation { label, detail } => {
489                write!(f, "invariant {label} violated: {detail}")
490            }
491        }
492    }
493}
494
495impl std::error::Error for AttrsViewError {}
496
497impl AttrsViewError {
498    /// Convenience constructor for a missing required key.
499    pub fn missing(key: &'static str) -> Self {
500        Self::MissingKey { key }
501    }
502
503    /// Convenience constructor for an invariant violation.
504    pub fn invariant(label: &'static str, detail: String) -> Self {
505        Self::InvariantViolation { label, detail }
506    }
507}
508
509/// Structured failure fields decoded from `FAILURE_*` attrs.
510#[derive(Debug, Clone, PartialEq)]
511pub struct FailureAttrs {
512    /// Error message describing the failure.
513    pub error_message: String,
514    /// Actor that caused the failure (root cause).
515    pub root_cause_actor: ActorAddr,
516    /// Display name of the root-cause actor, if available.
517    pub root_cause_name: Option<String>,
518    /// When the failure occurred.
519    pub occurred_at: SystemTime,
520    /// Whether this failure was propagated from a child.
521    pub is_propagated: bool,
522}
523
524/// Typed view over attrs for an actor node.
525#[derive(Debug, Clone, PartialEq)]
526pub struct ActorAttrsView {
527    /// Lifecycle status: "running", "stopped", "failed".
528    pub status: String,
529    /// Reason for stop/failure, if any.
530    pub status_reason: Option<String>,
531    /// Fully-qualified actor type name.
532    pub actor_type: String,
533    /// Stable per-instance identifier (`Uuid::now_v7`) as a string.
534    pub instance_id: String,
535    /// Number of messages processed.
536    pub messages_processed: u64,
537    /// When this actor was created.
538    pub created_at: Option<SystemTime>,
539    /// Name of the last message handler invoked.
540    pub last_handler: Option<String>,
541    /// Total CPU time in message handlers (microseconds).
542    pub total_processing_time_us: u64,
543    /// Accepted handler work not yet dequeued by the actor loop
544    /// (PD-5a/b). Independent diagnostic from `inbound_ordering`;
545    /// no arithmetic contract between the two -- see IO-3. Defaults
546    /// to 0 when the attr is absent.
547    pub queue_depth: u64,
548    /// Flight recorder JSON, if available.
549    pub flight_recorder: Option<String>,
550    /// Whether this is a system/infrastructure actor.
551    pub is_system: bool,
552    /// Per-session reorder state. `None` means no snapshot callback was
553    /// installed (structural absence per IO-1); `Some({enabled: false, ..})`
554    /// means buffering is disabled; `Some({enabled: true, ..})` means active.
555    /// Consumers must distinguish all three states.
556    pub inbound_ordering: Option<crate::ordering::OrderingSnapshot>,
557    /// Failure details, present iff status == "failed".
558    pub failure: Option<FailureAttrs>,
559}
560
561impl ActorAttrsView {
562    /// Decode from an `Attrs` bag (AV-2, AV-3). Requires `STATUS`
563    /// and `ACTOR_TYPE`. Enforces IA-3 (status_reason must not be
564    /// present for non-terminal status), IA-4 (failure attrs iff
565    /// failed), and failure completeness (if any required failure
566    /// key is present, all three required keys must be).
567    pub fn from_attrs(attrs: &Attrs) -> Result<Self, AttrsViewError> {
568        let status = attrs
569            .get(STATUS)
570            .ok_or_else(|| AttrsViewError::missing("status"))?
571            .clone();
572        let status_reason = attrs.get(STATUS_REASON).cloned();
573        let actor_type = attrs
574            .get(ACTOR_TYPE)
575            .ok_or_else(|| AttrsViewError::missing("actor_type"))?
576            .clone();
577        let instance_id = attrs
578            .get(INSTANCE_ID)
579            .ok_or_else(|| AttrsViewError::missing("instance_id"))?
580            .clone();
581        let messages_processed = *attrs.get(MESSAGES_PROCESSED).unwrap_or(&0);
582        let created_at = attrs.get(CREATED_AT).copied();
583        let last_handler = attrs.get(LAST_HANDLER).cloned();
584        let total_processing_time_us = *attrs.get(TOTAL_PROCESSING_TIME_US).unwrap_or(&0);
585        let queue_depth = *attrs.get(ACTOR_QUEUE_DEPTH).unwrap_or(&0);
586        let flight_recorder = attrs.get(FLIGHT_RECORDER).cloned();
587        let is_system = *attrs.get(IS_SYSTEM).unwrap_or(&false);
588        let inbound_ordering = attrs.get(INBOUND_ORDERING).cloned();
589
590        // IA-3 (one-sided): status_reason must not be present for
591        // non-terminal status. The converse is not enforced —
592        // terminal status without a reason is valid (clean shutdown).
593        let is_terminal = status == "stopped" || status == "failed";
594        if status_reason.is_some() && !is_terminal {
595            return Err(AttrsViewError::invariant(
596                "IA-3",
597                format!(
598                    "status_reason present but status is '{status}' (expected stopped or failed)"
599                ),
600            ));
601        }
602
603        // Decode failure attrs. If any of the three required
604        // failure keys is present, require all three.
605        // FAILURE_IS_PROPAGATED has a declare_attrs! default of
606        // false, so it always resolves via attrs.get() and needs
607        // no explicit presence check. FAILURE_ROOT_CAUSE_NAME is
608        // genuinely optional.
609        let has_any_failure = attrs.get(FAILURE_ERROR_MESSAGE).is_some()
610            || attrs.get(FAILURE_ROOT_CAUSE_ACTOR).is_some()
611            || attrs.get(FAILURE_OCCURRED_AT).is_some();
612
613        let failure = if has_any_failure {
614            let error_message = attrs
615                .get(FAILURE_ERROR_MESSAGE)
616                .ok_or_else(|| AttrsViewError::missing("failure_error_message"))?
617                .clone();
618            let root_cause_actor = attrs
619                .get(FAILURE_ROOT_CAUSE_ACTOR)
620                .ok_or_else(|| AttrsViewError::missing("failure_root_cause_actor"))?
621                .clone();
622            let root_cause_name = attrs.get(FAILURE_ROOT_CAUSE_NAME).cloned();
623            let occurred_at = *attrs
624                .get(FAILURE_OCCURRED_AT)
625                .ok_or_else(|| AttrsViewError::missing("failure_occurred_at"))?;
626            // Default false: failure originated at this actor.
627            let is_propagated = *attrs.get(FAILURE_IS_PROPAGATED).unwrap_or(&false);
628            Some(FailureAttrs {
629                error_message,
630                root_cause_actor,
631                root_cause_name,
632                occurred_at,
633                is_propagated,
634            })
635        } else {
636            None
637        };
638
639        // IA-4: failure attrs present iff status == "failed".
640        if status == "failed" && failure.is_none() {
641            return Err(AttrsViewError::invariant(
642                "IA-4",
643                "status is 'failed' but no failure_* attrs present".to_string(),
644            ));
645        }
646        if status != "failed" && failure.is_some() {
647            return Err(AttrsViewError::invariant(
648                "IA-4",
649                format!("status is '{status}' but failure_* attrs are present"),
650            ));
651        }
652
653        Ok(Self {
654            status,
655            status_reason,
656            actor_type,
657            instance_id,
658            messages_processed,
659            created_at,
660            last_handler,
661            total_processing_time_us,
662            queue_depth,
663            flight_recorder,
664            is_system,
665            inbound_ordering,
666            failure,
667        })
668    }
669
670    /// Encode into an `Attrs` bag (AV-1 round-trip producer).
671    pub fn to_attrs(&self) -> Attrs {
672        let mut attrs = Attrs::new();
673        attrs.set(STATUS, self.status.clone());
674        if let Some(reason) = &self.status_reason {
675            attrs.set(STATUS_REASON, reason.clone());
676        }
677        attrs.set(ACTOR_TYPE, self.actor_type.clone());
678        attrs.set(INSTANCE_ID, self.instance_id.clone());
679        attrs.set(MESSAGES_PROCESSED, self.messages_processed);
680        if let Some(t) = self.created_at {
681            attrs.set(CREATED_AT, t);
682        }
683        if let Some(handler) = &self.last_handler {
684            attrs.set(LAST_HANDLER, handler.clone());
685        }
686        attrs.set(TOTAL_PROCESSING_TIME_US, self.total_processing_time_us);
687        attrs.set(ACTOR_QUEUE_DEPTH, self.queue_depth);
688        if let Some(fr) = &self.flight_recorder {
689            attrs.set(FLIGHT_RECORDER, fr.clone());
690        }
691        attrs.set(IS_SYSTEM, self.is_system);
692        if let Some(snapshot) = &self.inbound_ordering {
693            attrs.set(INBOUND_ORDERING, snapshot.clone());
694        }
695        if let Some(fi) = &self.failure {
696            attrs.set(FAILURE_ERROR_MESSAGE, fi.error_message.clone());
697            attrs.set(FAILURE_ROOT_CAUSE_ACTOR, fi.root_cause_actor.clone());
698            if let Some(name) = &fi.root_cause_name {
699                attrs.set(FAILURE_ROOT_CAUSE_NAME, name.clone());
700            }
701            attrs.set(FAILURE_OCCURRED_AT, fi.occurred_at);
702            attrs.set(FAILURE_IS_PROPAGATED, fi.is_propagated);
703        }
704        attrs
705    }
706}
707
708/// Internal introspection result. Carries attrs as a JSON string.
709/// The mesh layer constructs the API-facing `NodePayload` (with
710/// `properties`) from this via `derive_properties`.
711///
712/// This is the internal wire type — it travels over handler ports
713/// via `IntrospectMessage`. The presentation-layer `NodePayload`
714/// (with `NodeProperties`) lives in `hyperactor_mesh::introspect`.
715#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
716pub struct IntrospectResult {
717    /// Addr identifying this node.
718    pub identity: IntrospectRef,
719    /// JSON-serialized `Attrs` bag containing introspection attributes.
720    pub attrs: String,
721    /// Child references the client can follow to descend the tree.
722    pub children: Vec<IntrospectRef>,
723    /// Parent reference for upward navigation.
724    pub parent: Option<IntrospectRef>,
725    /// When this data was captured.
726    pub as_of: SystemTime,
727}
728wirevalue::register_type!(IntrospectResult);
729
730/// Context for introspection query - what aspect of the actor to
731/// describe.
732///
733/// Infrastructure actors (e.g., ProcAgent, HostAgent)
734/// have dual nature: they manage entities (Proc, Host) while also
735/// being actors themselves. IntrospectView allows callers to
736/// specify which aspect to query.
737// TODO(monarch-introspection): IntrospectView currently uses
738// Entity/Actor naming. Consider renaming to runtime-neutral query
739// modes (e.g. Published/Runtime) to avoid mesh-domain wording in
740// hyperactor while preserving behavior and wire compatibility.
741#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Named)]
742pub enum IntrospectView {
743    /// Return managed-entity properties (Proc, Host, etc.) for
744    /// infrastructure actors.
745    Entity,
746    /// Return standard actor properties (status, messages_processed,
747    /// flight_recorder).
748    Actor,
749}
750wirevalue::register_type!(IntrospectView);
751
752/// Introspection query sent to any actor.
753///
754/// `Query` asks the actor to describe itself. `QueryChild` asks the
755/// actor to describe one of its non-addressable children — an entity
756/// that appears in the navigation tree but has no mailbox of its own
757/// (e.g. a system proc owned by a host). The parent actor answers on
758/// the child's behalf.
759#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
760pub enum IntrospectMessage {
761    /// "Describe yourself."
762    Query {
763        /// View context - Entity or Actor.
764        view: IntrospectView,
765        /// Reply port receiving the actor's self-description.
766        reply: OncePortRef<IntrospectResult>,
767    },
768    /// "Describe one of your children."
769    QueryChild {
770        /// Addr identifying the child to describe.
771        child_ref: Addr,
772        /// Reply port receiving the child's description.
773        reply: OncePortRef<IntrospectResult>,
774    },
775}
776wirevalue::register_type!(IntrospectMessage);
777
778/// Structured tracing event from the actor-local flight recorder.
779///
780/// Deserialization target for the `FLIGHT_RECORDER` attrs JSON string.
781#[derive(Debug, Clone, Serialize, Deserialize)]
782pub struct RecordedEvent {
783    /// ISO 8601 timestamp of the event.
784    pub timestamp: String,
785    /// Monotonic sequence number for ordering.
786    #[serde(default)]
787    pub seq: usize,
788    /// Event level (INFO, DEBUG, etc.).
789    pub level: String,
790    /// Event target (module path).
791    #[serde(default)]
792    pub target: String,
793    /// Event name.
794    pub name: String,
795    /// Event fields as JSON.
796    pub fields: serde_json::Value,
797}
798
799/// Format a [`SystemTime`] as an ISO 8601 timestamp with millisecond
800/// precision.
801pub fn format_timestamp(time: SystemTime) -> String {
802    humantime::format_rfc3339_millis(time).to_string()
803}
804
805/// Build a JSON-serialized `Attrs` string from values already
806/// computed by `live_actor_payload`. Reuses the same data — no
807/// redundant reads from `InstanceCell`.
808///
809/// Populates actor-runtime keys (STATUS, ACTOR_TYPE, etc.),
810/// decomposes the status prefix protocol into STATUS + STATUS_REASON,
811/// and decomposes failure fields into individual FAILURE_* attrs.
812///
813/// Starts from a fresh `Attrs` bag — published attrs (node_type,
814/// addr, etc.) are NOT included. This ensures the Actor view
815/// produces actor-only data; the Entity view handles published
816/// attrs separately.
817/// Failure fields extracted from a supervision event.
818struct FailureSnapshot {
819    error_message: String,
820    root_cause_actor: ActorAddr,
821    root_cause_name: Option<String>,
822    occurred_at: SystemTime,
823    is_propagated: bool,
824}
825
826/// Pre-computed actor state for building the attrs JSON string.
827/// Avoids redundant InstanceCell reads — `live_actor_payload`
828/// computes these once and passes them in.
829struct ActorSnapshot {
830    status_str: String,
831    is_system: bool,
832    last_handler: Option<String>,
833    flight_recorder: Option<String>,
834    failure: Option<FailureSnapshot>,
835}
836
837fn build_actor_attrs(cell: &crate::InstanceCell, snap: &ActorSnapshot) -> String {
838    // Actor view builds a clean attrs bag with only actor-runtime
839    // keys. Published attrs (node_type, addr, etc.) belong to the
840    // Entity view — they are NOT merged here. This ensures that
841    // e.g. a HostMeshAgent resolved via Actor view produces Actor
842    // properties, not Host properties.
843    let mut attrs = hyperactor_config::Attrs::new();
844
845    // IA-3: status_reason present iff status carries a reason.
846    if let Some(reason) = snap.status_str.strip_prefix("stopped:") {
847        attrs.set(STATUS, "stopped".to_string());
848        attrs.set(STATUS_REASON, reason.trim().to_string());
849    } else if let Some(reason) = snap.status_str.strip_prefix("failed:") {
850        attrs.set(STATUS, "failed".to_string());
851        attrs.set(STATUS_REASON, reason.trim().to_string());
852    } else {
853        attrs.set(STATUS, snap.status_str.clone());
854        // IA-3: no status_reason for non-terminal states —
855        // guaranteed by fresh Attrs bag.
856    }
857
858    attrs.set(ACTOR_TYPE, cell.actor_type_name().to_string());
859    attrs.set(MESSAGES_PROCESSED, cell.num_processed_messages());
860    attrs.set(CREATED_AT, cell.created_at());
861    attrs.set(TOTAL_PROCESSING_TIME_US, cell.total_processing_time_us());
862    attrs.set(IS_SYSTEM, snap.is_system);
863    attrs.set(INSTANCE_ID, cell.instance_id().to_string());
864    attrs.set(ACTOR_QUEUE_DEPTH, cell.queue_depth());
865
866    if let Some(snapshot) = cell.inbound_ordering_snapshot() {
867        // TODO: truncation / filtering of long session lists belongs at
868        // the API/DTO layer, not here. `build_actor_attrs` returns the
869        // full per-actor snapshot verbatim so consumers can decide.
870        attrs.set(INBOUND_ORDERING, snapshot);
871    }
872
873    if let Some(handler) = &snap.last_handler {
874        attrs.set(LAST_HANDLER, handler.clone());
875    }
876    if let Some(fr) = &snap.flight_recorder {
877        attrs.set(FLIGHT_RECORDER, fr.clone());
878    }
879
880    // IA-4 / FI-A1: failure attrs present iff status == "failed".
881    if let Some(fi) = &snap.failure {
882        attrs.set(FAILURE_ERROR_MESSAGE, fi.error_message.clone());
883        attrs.set(FAILURE_ROOT_CAUSE_ACTOR, fi.root_cause_actor.clone());
884        if let Some(name) = &fi.root_cause_name {
885            attrs.set(FAILURE_ROOT_CAUSE_NAME, name.clone());
886        }
887        attrs.set(FAILURE_OCCURRED_AT, fi.occurred_at);
888        attrs.set(FAILURE_IS_PROPAGATED, fi.is_propagated);
889    }
890    // IA-4: failure attrs absent when not failed — guaranteed by
891    // starting from a fresh Attrs bag (no stale keys possible).
892
893    let core_json = serde_json::to_string(&attrs).unwrap_or_else(|_| "{}".to_string());
894
895    // Merge actor-supplied attrs (the generic seam, AS-1). `Attrs::merge`
896    // overwrites the receiver's keys with the argument's, so merging the
897    // core bag *onto* the actor snapshot makes core keys win on collision
898    // (AS-2, the Actor-view analog of IA-2). No snapshot → the core bag is
899    // returned unchanged; if the merged bag somehow fails to serialize,
900    // fall back to the core-only JSON so a bad snapshot can never empty or
901    // invalidate the actor view (AS-3).
902    match cell.actor_attrs_snapshot() {
903        None => core_json,
904        Some(mut merged) => {
905            merged.merge(attrs);
906            serde_json::to_string(&merged).unwrap_or(core_json)
907        }
908    }
909}
910
911/// Build an [`IntrospectResult`] from live [`InstanceCell`] state.
912///
913/// Reads the current live status and last handler directly from
914/// the cell. Used by the introspect task (which runs outside
915/// the actor's message loop) and by `Instance::introspect_payload`.
916pub fn live_actor_payload(cell: &InstanceCell) -> IntrospectResult {
917    let status = cell.status().borrow().clone();
918    live_actor_payload_with_status(cell, &status)
919}
920
921fn live_actor_payload_with_status(
922    cell: &InstanceCell,
923    status: &crate::actor::ActorStatus,
924) -> IntrospectResult {
925    let actor_id = cell.actor_addr();
926    let last_handler = cell.last_message_handler();
927
928    let children: Vec<IntrospectRef> = cell
929        .child_actor_ids()
930        .into_iter()
931        .map(IntrospectRef::Actor)
932        .collect();
933
934    let events = cell.recording().tail();
935    let flight_recorder_events: Vec<RecordedEvent> = events
936        .into_iter()
937        .map(|event| RecordedEvent {
938            timestamp: format_timestamp(event.time),
939            seq: event.seq,
940            level: event.metadata.level().to_string(),
941            target: event.metadata.target().to_string(),
942            name: event.metadata.name().to_string(),
943            fields: event.json_value(),
944        })
945        .collect();
946
947    let flight_recorder = if flight_recorder_events.is_empty() {
948        None
949    } else {
950        serde_json::to_string(&flight_recorder_events).ok()
951    };
952
953    let supervisor = cell
954        .parent()
955        .map(|p| IntrospectRef::Actor(p.actor_addr().clone()));
956
957    // FI-3: failure_info is computed from the same status value as
958    // actor_status, ensuring they agree on whether the actor failed.
959    let failure = if status.is_failed() {
960        cell.supervision_event().and_then(|event| {
961            let root = event.actually_failing_actor()?;
962            Some(FailureSnapshot {
963                error_message: event.actor_status.to_string(),
964                root_cause_actor: root.actor_id.clone(),
965                root_cause_name: root.display_name.clone(),
966                occurred_at: event.occurred_at,
967                is_propagated: root.actor_id != actor_id.clone(),
968            })
969        })
970    } else {
971        None
972    };
973
974    let snap = ActorSnapshot {
975        status_str: status.to_string(),
976        is_system: cell.is_system(),
977        last_handler: last_handler.map(|info| info.to_string()),
978        flight_recorder,
979        failure,
980    };
981
982    let attrs = build_actor_attrs(cell, &snap);
983
984    IntrospectResult {
985        identity: IntrospectRef::Actor(actor_id.clone()),
986        attrs,
987        children,
988        parent: supervisor,
989        as_of: SystemTime::now(),
990    }
991}
992
993/// Serve introspection for one actor.
994///
995/// This runs on a dedicated Tokio task owned by the actor runtime. It
996/// handles [`IntrospectMessage`] by reading [`InstanceCell`] directly
997/// and replying through the owning [`Proc`](crate::Proc). The actor's
998/// message loop never sees these messages, so a stuck actor can still
999/// be introspected.
1000///
1001/// The task's lifetime is controlled by `shutdown`, not by terminal
1002/// [`ActorStatus`](crate::actor::ActorStatus). Runtime teardown sends
1003/// the final status through `shutdown`; this task then builds and
1004/// stores the terminated snapshot with that status and exits. The
1005/// actor's serving loop, or the proc-managed lifecycle for detached
1006/// instances, joins this task before publishing terminal status, so
1007/// terminal status remains the single authoritative signal that the
1008/// actor's full runtime, including introspection, has shut down.
1009///
1010/// If the introspect receiver closes before shutdown, the task stops
1011/// accepting queries but remains alive until runtime shutdown. This
1012/// preserves the shutdown path that stores the post-mortem snapshot and
1013/// breaks the `InstanceCell` reference cycle.
1014///
1015/// # Invariants exercised
1016///
1017/// Exercises S1, S2, S4, S5, S6, S11 (see module doc).
1018pub(crate) async fn serve_introspect(
1019    cell: InstanceCell,
1020    mut receiver: crate::mailbox::PortReceiver<IntrospectMessage>,
1021    mut shutdown: tokio::sync::oneshot::Receiver<crate::actor::ActorStatus>,
1022) {
1023    use crate::mailbox::PortSender as _;
1024
1025    // Runtime shutdown, not terminal status, owns this task's lifetime.
1026    // Terminal status is published only after this task snapshots and exits.
1027    let mut receiver_open = true;
1028
1029    loop {
1030        let msg = tokio::select! {
1031            msg = receiver.recv(), if receiver_open => {
1032                match msg {
1033                    Ok(msg) => msg,
1034                    Err(_) => {
1035                        receiver_open = false;
1036                        continue;
1037                    }
1038                }
1039            }
1040            terminal_status = &mut shutdown => {
1041                let Ok(terminal_status) = terminal_status else {
1042                    break;
1043                };
1044                let snapshot = live_actor_payload_with_status(&cell, &terminal_status);
1045                cell.store_terminated_snapshot(snapshot);
1046                break;
1047            }
1048        };
1049
1050        let result = match msg {
1051            IntrospectMessage::Query { view, reply } => {
1052                let payload = match view {
1053                    IntrospectView::Entity => match cell.published_attrs() {
1054                        Some(published) => {
1055                            let attrs_json =
1056                                serde_json::to_string(&published).unwrap_or_else(|_| "{}".into());
1057                            let children: Vec<IntrospectRef> =
1058                                published.get(CHILDREN).cloned().unwrap_or_default();
1059                            IntrospectResult {
1060                                identity: IntrospectRef::Actor(cell.actor_addr().clone()),
1061                                attrs: attrs_json,
1062                                children,
1063                                parent: cell
1064                                    .parent()
1065                                    .map(|p| IntrospectRef::Actor(p.actor_addr().clone())),
1066                                as_of: SystemTime::now(),
1067                            }
1068                        }
1069                        None => live_actor_payload(&cell),
1070                    },
1071                    IntrospectView::Actor => live_actor_payload(&cell),
1072                };
1073                cell.proc().serialize_and_send_once(
1074                    reply,
1075                    payload,
1076                    crate::mailbox::monitored_return_handle(),
1077                )
1078            }
1079            IntrospectMessage::QueryChild { child_ref, reply } => {
1080                let child_ref_: Addr = child_ref.clone();
1081                let payload = cell.query_child(&child_ref_).unwrap_or_else(|| {
1082                    let mut error_attrs = hyperactor_config::Attrs::new();
1083                    error_attrs.set(ERROR_CODE, "not_found".to_string());
1084                    error_attrs.set(
1085                        ERROR_MESSAGE,
1086                        format!("child {} not found (no callback registered)", child_ref),
1087                    );
1088                    // Use the queried child_ref as identity for the error node.
1089                    let identity = match &child_ref {
1090                        Addr::Proc(id) => IntrospectRef::Proc(id.clone()),
1091                        Addr::Actor(id) => IntrospectRef::Actor(id.clone()),
1092                        Addr::Port(id) => IntrospectRef::Actor(id.actor_addr()),
1093                    };
1094                    IntrospectResult {
1095                        identity,
1096                        attrs: serde_json::to_string(&error_attrs)
1097                            .unwrap_or_else(|_| "{}".to_string()),
1098                        children: Vec::new(),
1099                        parent: None,
1100                        as_of: SystemTime::now(),
1101                    }
1102                });
1103                cell.proc().serialize_and_send_once(
1104                    reply,
1105                    payload,
1106                    crate::mailbox::monitored_return_handle(),
1107                )
1108            }
1109        };
1110        if let Err(e) = result {
1111            tracing::debug!("introspect reply failed: {e}");
1112        }
1113    }
1114    tracing::debug!(
1115        actor_id = %cell.actor_addr(),
1116        "introspect task exiting"
1117    );
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122    use super::*;
1123    use crate::ActorAddr;
1124    use crate::ProcAddr;
1125    use crate::actor::ActorErrorKind;
1126    use crate::actor::ActorStatus;
1127    use crate::channel::ChannelAddr;
1128    use crate::supervision::ActorSupervisionEvent;
1129
1130    /// Exercises IK-1 (see module doc).
1131    #[test]
1132    fn test_introspect_keys_are_tagged() {
1133        let cases = vec![
1134            ("status", STATUS.attrs()),
1135            ("status_reason", STATUS_REASON.attrs()),
1136            ("actor_type", ACTOR_TYPE.attrs()),
1137            ("messages_processed", MESSAGES_PROCESSED.attrs()),
1138            ("created_at", CREATED_AT.attrs()),
1139            ("last_handler", LAST_HANDLER.attrs()),
1140            ("total_processing_time_us", TOTAL_PROCESSING_TIME_US.attrs()),
1141            ("flight_recorder", FLIGHT_RECORDER.attrs()),
1142            ("is_system", IS_SYSTEM.attrs()),
1143            ("children", CHILDREN.attrs()),
1144            ("error_code", ERROR_CODE.attrs()),
1145            ("error_message", ERROR_MESSAGE.attrs()),
1146            ("failure_error_message", FAILURE_ERROR_MESSAGE.attrs()),
1147            ("failure_root_cause_actor", FAILURE_ROOT_CAUSE_ACTOR.attrs()),
1148            ("failure_root_cause_name", FAILURE_ROOT_CAUSE_NAME.attrs()),
1149            ("failure_occurred_at", FAILURE_OCCURRED_AT.attrs()),
1150            ("failure_is_propagated", FAILURE_IS_PROPAGATED.attrs()),
1151            ("instance_id", INSTANCE_ID.attrs()),
1152            ("queue_depth", ACTOR_QUEUE_DEPTH.attrs()),
1153            ("inbound_ordering", INBOUND_ORDERING.attrs()),
1154        ];
1155
1156        for (expected_name, meta) in &cases {
1157            // IK-1: see module doc.
1158            let introspect = meta
1159                .get(INTROSPECT)
1160                .unwrap_or_else(|| panic!("{expected_name}: missing INTROSPECT meta-attr"));
1161            assert_eq!(
1162                introspect.name, *expected_name,
1163                "short name mismatch for {expected_name}"
1164            );
1165            assert!(
1166                !introspect.desc.is_empty(),
1167                "{expected_name}: desc should not be empty"
1168            );
1169        }
1170
1171        // Exhaustiveness: verify cases covers all INTROSPECT-tagged
1172        // keys declared in this module.
1173        use hyperactor_config::attrs::AttrKeyInfo;
1174        let registry_count = inventory::iter::<AttrKeyInfo>()
1175            .filter(|info| {
1176                info.name.starts_with("hyperactor::introspect::")
1177                    && info.meta.get(INTROSPECT).is_some()
1178            })
1179            .count();
1180        assert_eq!(
1181            cases.len(),
1182            registry_count,
1183            "test must cover all INTROSPECT-tagged keys in this module"
1184        );
1185    }
1186
1187    /// Exercises IK-2 (see module doc).
1188    #[test]
1189    fn test_introspect_short_names_are_globally_unique() {
1190        use hyperactor_config::attrs::AttrKeyInfo;
1191
1192        let mut seen = std::collections::HashMap::new();
1193        for info in inventory::iter::<AttrKeyInfo>() {
1194            let Some(introspect) = info.meta.get(INTROSPECT) else {
1195                continue;
1196            };
1197            // Metadata quality: every tagged key must have
1198            // non-empty name and desc.
1199            assert!(
1200                !introspect.name.is_empty(),
1201                "INTROSPECT key {:?} has empty name",
1202                info.name
1203            );
1204            assert!(
1205                !introspect.desc.is_empty(),
1206                "INTROSPECT key {:?} has empty desc",
1207                info.name
1208            );
1209            if let Some(prev_fq) = seen.insert(introspect.name.clone(), info.name) {
1210                panic!(
1211                    "IK-2 violation: duplicate short name {:?} declared by both {:?} and {:?}",
1212                    introspect.name, prev_fq, info.name
1213                );
1214            }
1215        }
1216    }
1217
1218    // IA-1 tests require spawning actors and live in actor.rs
1219    // where #[hyperactor::export] and test infrastructure are
1220    // available. IA-3 and IA-4 are tested below at the view level.
1221
1222    fn running_actor_attrs() -> Attrs {
1223        let mut attrs = Attrs::new();
1224        attrs.set(STATUS, "running".to_string());
1225        attrs.set(ACTOR_TYPE, "MyActor".to_string());
1226        attrs.set(INSTANCE_ID, uuid::Uuid::from_u128(0xfeed_face).to_string());
1227        attrs.set(MESSAGES_PROCESSED, 42u64);
1228        attrs.set(CREATED_AT, SystemTime::UNIX_EPOCH);
1229        attrs.set(IS_SYSTEM, false);
1230        attrs
1231    }
1232
1233    fn test_actor_id(proc_name: &str, actor_name: &str) -> ActorAddr {
1234        ProcAddr::singleton(ChannelAddr::Local(0), proc_name).actor_addr(actor_name)
1235    }
1236
1237    fn failed_actor_attrs() -> Attrs {
1238        let mut attrs = running_actor_attrs();
1239        attrs.set(STATUS, "failed".to_string());
1240        attrs.set(STATUS_REASON, "something broke".to_string());
1241        attrs.set(FAILURE_ERROR_MESSAGE, "boom".to_string());
1242        attrs.set(FAILURE_ROOT_CAUSE_ACTOR, test_actor_id("proc", "other"));
1243        attrs.set(FAILURE_ROOT_CAUSE_NAME, "OtherActor".to_string());
1244        attrs.set(FAILURE_OCCURRED_AT, SystemTime::UNIX_EPOCH);
1245        attrs.set(FAILURE_IS_PROPAGATED, true);
1246        attrs
1247    }
1248
1249    /// AV-1: from_attrs(to_attrs(v)) == v.
1250    #[test]
1251    fn test_actor_view_round_trip_running() {
1252        let view = ActorAttrsView::from_attrs(&running_actor_attrs()).unwrap();
1253        assert_eq!(view.status, "running");
1254        assert_eq!(view.actor_type, "MyActor");
1255        assert_eq!(view.messages_processed, 42);
1256        // Default values for the new fields when not set in the
1257        // running_actor_attrs() fixture.
1258        assert_eq!(view.queue_depth, 0);
1259        assert!(view.inbound_ordering.is_none());
1260        assert!(view.failure.is_none());
1261
1262        let round_tripped = ActorAttrsView::from_attrs(&view.to_attrs()).unwrap();
1263        assert_eq!(round_tripped, view);
1264    }
1265
1266    /// AV-1.
1267    #[test]
1268    fn test_actor_view_round_trip_failed() {
1269        let view = ActorAttrsView::from_attrs(&failed_actor_attrs()).unwrap();
1270        assert_eq!(view.status, "failed");
1271        let fi = view.failure.as_ref().unwrap();
1272        assert_eq!(fi.error_message, "boom");
1273        assert!(fi.is_propagated);
1274
1275        let round_tripped = ActorAttrsView::from_attrs(&view.to_attrs()).unwrap();
1276        assert_eq!(round_tripped, view);
1277    }
1278
1279    /// AV-2: missing required key rejected.
1280    #[test]
1281    fn test_actor_view_missing_status() {
1282        let mut attrs = Attrs::new();
1283        attrs.set(ACTOR_TYPE, "X".to_string());
1284        let err = ActorAttrsView::from_attrs(&attrs).unwrap_err();
1285        assert_eq!(err, AttrsViewError::MissingKey { key: "status" });
1286    }
1287
1288    /// AV-2.
1289    #[test]
1290    fn test_actor_view_missing_actor_type() {
1291        let mut attrs = Attrs::new();
1292        attrs.set(STATUS, "running".to_string());
1293        let err = ActorAttrsView::from_attrs(&attrs).unwrap_err();
1294        assert_eq!(err, AttrsViewError::MissingKey { key: "actor_type" });
1295    }
1296
1297    /// AV-2: `instance_id` is a required key on the actor view --
1298    /// every live actor's attrs bag carries one.
1299    #[test]
1300    fn test_actor_view_missing_instance_id() {
1301        let mut attrs = Attrs::new();
1302        attrs.set(STATUS, "running".to_string());
1303        attrs.set(ACTOR_TYPE, "X".to_string());
1304        let err = ActorAttrsView::from_attrs(&attrs).unwrap_err();
1305        assert_eq!(err, AttrsViewError::MissingKey { key: "instance_id" });
1306    }
1307
1308    /// AV-1 + IO-1: round-trip with inbound_ordering = Some(...).
1309    /// Pins that the typed `OrderingSnapshot` survives the
1310    /// Attrs encode/decode boundary.
1311    #[test]
1312    fn test_actor_view_round_trip_with_inbound_ordering() {
1313        use crate::ordering::OrderingSessionSnapshot;
1314        use crate::ordering::OrderingSnapshot;
1315
1316        let session_addr = test_actor_id("sender_proc", "sender_actor");
1317        let snapshot = OrderingSnapshot {
1318            enabled: true,
1319            sessions: vec![OrderingSessionSnapshot {
1320                session_id: uuid::Uuid::from_u128(7),
1321                sender: Some(session_addr),
1322                last_released_seq: 3,
1323                expected_next_seq: 4,
1324                buffered_count: 2,
1325                oldest_buffered_seq: Some(5),
1326                newest_buffered_seq: Some(6),
1327            }],
1328            skipped_session_count: 0,
1329        };
1330
1331        let mut attrs = running_actor_attrs();
1332        attrs.set(ACTOR_QUEUE_DEPTH, 7u64);
1333        attrs.set(INBOUND_ORDERING, snapshot.clone());
1334
1335        let view = ActorAttrsView::from_attrs(&attrs).unwrap();
1336        assert_eq!(view.queue_depth, 7);
1337        assert_eq!(view.inbound_ordering.as_ref(), Some(&snapshot));
1338
1339        let round_tripped = ActorAttrsView::from_attrs(&view.to_attrs()).unwrap();
1340        assert_eq!(round_tripped, view);
1341    }
1342
1343    /// AV-1 + IO-1: round-trip with inbound_ordering = None survives
1344    /// cleanly. Pins the "structural absence" semantics: round-tripping
1345    /// the view does not invent a Some({enabled: false}) value.
1346    #[test]
1347    fn test_actor_view_round_trip_without_inbound_ordering() {
1348        let view = ActorAttrsView::from_attrs(&running_actor_attrs()).unwrap();
1349        assert!(view.inbound_ordering.is_none());
1350
1351        let round_tripped = ActorAttrsView::from_attrs(&view.to_attrs()).unwrap();
1352        assert!(round_tripped.inbound_ordering.is_none());
1353        assert_eq!(round_tripped, view);
1354    }
1355
1356    #[test]
1357    fn test_actor_view_ia3_rejects_reason_on_running() {
1358        let mut attrs = running_actor_attrs();
1359        attrs.set(STATUS_REASON, "should not be here".to_string());
1360        let err = ActorAttrsView::from_attrs(&attrs).unwrap_err();
1361        assert!(matches!(
1362            err,
1363            AttrsViewError::InvariantViolation { label: "IA-3", .. }
1364        ));
1365    }
1366
1367    #[test]
1368    fn test_actor_view_ia3_allows_terminal_without_reason() {
1369        let mut attrs = running_actor_attrs();
1370        attrs.set(STATUS, "stopped".to_string());
1371        // No status_reason — should be fine.
1372        let view = ActorAttrsView::from_attrs(&attrs).unwrap();
1373        assert_eq!(view.status, "stopped");
1374        assert!(view.status_reason.is_none());
1375    }
1376
1377    #[test]
1378    fn test_actor_view_ia4_rejects_failed_without_failure_attrs() {
1379        let mut attrs = running_actor_attrs();
1380        attrs.set(STATUS, "failed".to_string());
1381        // No failure_* keys.
1382        let err = ActorAttrsView::from_attrs(&attrs).unwrap_err();
1383        assert!(matches!(
1384            err,
1385            AttrsViewError::InvariantViolation { label: "IA-4", .. }
1386        ));
1387    }
1388
1389    #[test]
1390    fn test_actor_view_ia4_rejects_failure_attrs_on_running() {
1391        let mut attrs = running_actor_attrs();
1392        attrs.set(FAILURE_ERROR_MESSAGE, "boom".to_string());
1393        attrs.set(FAILURE_ROOT_CAUSE_ACTOR, test_actor_id("proc", "x"));
1394        attrs.set(FAILURE_OCCURRED_AT, SystemTime::UNIX_EPOCH);
1395        let err = ActorAttrsView::from_attrs(&attrs).unwrap_err();
1396        assert!(matches!(
1397            err,
1398            AttrsViewError::InvariantViolation { label: "IA-4", .. }
1399        ));
1400    }
1401
1402    /// AV-2: partial failure set → missing key.
1403    #[test]
1404    fn test_actor_view_partial_failure_attrs_rejected() {
1405        let mut attrs = running_actor_attrs();
1406        attrs.set(STATUS, "failed".to_string());
1407        // Only one of the three required failure keys.
1408        attrs.set(FAILURE_ERROR_MESSAGE, "boom".to_string());
1409        let err = ActorAttrsView::from_attrs(&attrs).unwrap_err();
1410        assert_eq!(
1411            err,
1412            AttrsViewError::MissingKey {
1413                key: "failure_root_cause_actor"
1414            }
1415        );
1416    }
1417
1418    /// Exercises FI-7 and FI-8 (see module doc): when a parent fails
1419    /// due to an unhandled Stopped child event, structured failure
1420    /// attrs must name the stopped child as
1421    /// `failure_root_cause_actor` (FI-7) and report
1422    /// `failure_is_propagated == true` (FI-8).
1423    ///
1424    /// Partially white-box: re-creates `FailureSnapshot` construction
1425    /// from `live_actor_payload` because that function requires an
1426    /// `InstanceCell`. This test will fail if
1427    /// `actually_failing_actor()` regresses, because that helper is
1428    /// the shared decision point for root-cause attribution. See
1429    /// `test_propagated_failure_info` in `proc.rs` for end-to-end
1430    /// integration coverage.
1431    #[test]
1432    fn test_fi7_fi8_propagated_stopped_child() {
1433        let proc_id = ProcAddr::singleton(ChannelAddr::Local(0), "test_proc");
1434        let child_id = proc_id.actor_addr("proc_agent");
1435        let parent_id = proc_id.actor_addr("mesh_actor");
1436
1437        let child_event = ActorSupervisionEvent::new(
1438            child_id.clone(),
1439            Some("proc_agent".into()),
1440            ActorStatus::Stopped("host died".into()),
1441            None,
1442        );
1443        let parent_event = ActorSupervisionEvent::new(
1444            parent_id.clone(),
1445            Some("mesh_actor".into()),
1446            ActorStatus::Failed(ActorErrorKind::UnhandledSupervisionEvent(Box::new(
1447                child_event,
1448            ))),
1449            None,
1450        );
1451
1452        // -- reproduce FailureSnapshot construction (same logic as
1453        // live_actor_payload) --
1454        let root = parent_event
1455            .actually_failing_actor()
1456            .expect("parent_event is a failure");
1457        let snap = FailureSnapshot {
1458            error_message: parent_event.actor_status.to_string(),
1459            root_cause_actor: root.actor_id.clone(),
1460            root_cause_name: root.display_name.clone(),
1461            occurred_at: parent_event.occurred_at,
1462            is_propagated: root.actor_id != parent_id,
1463        };
1464
1465        // FI-7: failure_root_cause_actor is the stopped child.
1466        assert_eq!(snap.root_cause_actor, child_id);
1467        // FI-8: failure_is_propagated is true.
1468        assert!(snap.is_propagated);
1469        // root_cause_name pinned before round-trip.
1470        assert_eq!(snap.root_cause_name.as_deref(), Some("proc_agent"));
1471
1472        // -- attrs round-trip through ActorAttrsView --
1473        let mut attrs = failed_actor_attrs();
1474        attrs.set(FAILURE_ERROR_MESSAGE, snap.error_message);
1475        attrs.set(FAILURE_ROOT_CAUSE_ACTOR, snap.root_cause_actor.clone());
1476        if let Some(name) = &snap.root_cause_name {
1477            attrs.set(FAILURE_ROOT_CAUSE_NAME, name.clone());
1478        }
1479        attrs.set(FAILURE_OCCURRED_AT, snap.occurred_at);
1480        attrs.set(FAILURE_IS_PROPAGATED, snap.is_propagated);
1481
1482        let view = ActorAttrsView::from_attrs(&attrs).unwrap();
1483        assert_eq!(view.status, "failed");
1484        let fi = view.failure.as_ref().expect("failure_info must be present");
1485        // FI-7: failure_root_cause_actor survives attrs round-trip.
1486        assert_eq!(fi.root_cause_actor, child_id);
1487        // FI-8: failure_is_propagated survives attrs round-trip.
1488        assert!(fi.is_propagated);
1489        // root_cause_name also survives.
1490        assert_eq!(fi.root_cause_name.as_deref(), Some("proc_agent"));
1491    }
1492}