Skip to main content

hyperactor_mesh/
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//! Mesh-topology introspection types and attrs.
10//!
11//! This module owns the typed internal model used by mesh-admin and the
12//! TUI: mesh-topology attr keys, typed attrs views, `NodeRef`, and the
13//! domain `NodePayload` / `NodeProperties` / `FailureInfo` values derived
14//! from `hyperactor::introspect::IntrospectResult`.
15//!
16//! These keys are published by `HostMeshAgent`, `ProcAgent`, and
17//! `MeshAdminAgent` to describe mesh topology (hosts, procs, root).
18//! Actor-runtime keys (status, actor_type, messages_processed, etc.) are
19//! declared in `hyperactor::introspect`.
20//!
21//! The HTTP wire representations live in [`dto`]. That submodule owns the
22//! curl-friendly JSON contract, schema/OpenAPI generation, and boundary
23//! invariants for string-encoded references and timestamps. This module
24//! keeps the internal typed invariants.
25//!
26//! These invariants govern the introspection model and derived
27//! payloads exposed by mesh-admin; lower-level runtime accounting
28//! invariants remain owned by the runtime modules that produce those
29//! values.
30//!
31//! See `hyperactor::introspect` for naming convention, invariant
32//! labels, and the `IntrospectAttr` meta-attribute pattern.
33//!
34//! ## Mesh key invariants (MK-*)
35//!
36//! - **MK-1 (metadata completeness):** Every mesh-topology
37//!   introspection key must carry `@meta(INTROSPECT = ...)` with
38//!   non-empty `name` and `desc`.
39//! - **MK-2 (short-name uniqueness):** Covered by
40//!   `test_introspect_short_names_are_globally_unique` in
41//!   `hyperactor::introspect` (cross-crate).
42//!
43//! ## Proc debug stats invariants (PD-*)
44//!
45//! These invariants govern the proc-debug introspection surface
46//! exposed by mesh-admin: proc attrs, typed proc views, and the
47//! proc-debug portion of `NodeProperties::Proc`.
48//!
49//! They do not define proc runtime mechanics. The underlying
50//! per-actor queue-depth accounting invariants live in
51//! `hyperactor::proc`; this module owns the proc-level debug values
52//! derived from that runtime state.
53//!
54//! - **PD-1:** `actor_work_queue_depth_max <=
55//!   actor_work_queue_depth_total`.
56//! - **PD-2:** `process_rss_bytes` and `process_vm_size_bytes` are
57//!   `None` on non-Linux or read failure. Never fabricated.
58//! - **PD-3:** All debug fields default to zero/None for backward
59//!   compatibility. Old procs that haven't published yet produce a
60//!   valid `ProcDebugStats::default()`.
61//! - **PD-4:** Queue depth aggregation covers live actors only.
62//!   Stopped/retained actor snapshots are excluded.
63//! - **PD-5:** See `hyperactor::proc` module doc for the per-actor
64//!   queue depth accounting invariants (PD-5a through PD-5e).
65//!
66//! ## HTTP boundary invariants (HB-*)
67//!
68//! These govern the HTTP DTO layer in [`dto`].
69//!
70//! - **HB-1 (typed-internal, string-external):** `NodeRef`, `ActorAddr`,
71//!   `ProcAddr`, and `SystemTime` are typed Rust values internally. At the
72//!   HTTP JSON boundary, [`dto::NodePayloadDto`],
73//!   [`dto::NodePropertiesDto`], and [`dto::FailureInfoDto`] encode them
74//!   as canonical strings.
75//! - **HB-2 (round-trip):** The HTTP string forms round-trip through the
76//!   internal typed parsers (`NodeRef::from_str`, `ActorAddr::from_str`,
77//!   `humantime::parse_rfc3339`). Timestamps are formatted at
78//!   millisecond precision; sub-millisecond values are truncated at
79//!   the boundary.
80//! - **HB-3 (schema-honesty):** Schema/OpenAPI are generated from the DTO
81//!   types, so the published schema reflects the actual wire format rather
82//!   than the internal domain representation.
83//!
84//! ## Attrs invariants (IA-*)
85//!
86//! These govern how `IntrospectResult.attrs` is built in
87//! `hyperactor::introspect` and how `properties` is derived via
88//! `derive_properties`.
89//!
90//! - **IA-1 (attrs-json):** `IntrospectResult.attrs` is always a
91//!   valid JSON object string.
92//! - **IA-2 (runtime-precedence):** Runtime-owned introspection keys
93//!   override any same-named keys in published attrs.
94//! - **IA-3 (status-shape):** `status_reason` is present in attrs
95//!   iff the status string carries a reason.
96//! - **IA-4 (failure-shape):** `failure_*` attrs are present iff
97//!   effective status is `failed`.
98//! - **IA-5 (payload-totality):** Every `IntrospectResult` sets
99//!   `attrs` -- never omitted, never null.
100//! - **IA-6 (open-row-forward-compat):** View decoders ignore
101//!   unknown attrs keys; only required known keys and local
102//!   invariants affect decoding outcome. Concretized by AV-3.
103//!
104//! ## Attrs view invariants (AV-*)
105//!
106//! These govern the typed view layer (`*AttrsView` structs).
107//!
108//! - **AV-1 (view-roundtrip):** For each view V,
109//!   `V::from_attrs(&v.to_attrs()) == Ok(v)` (modulo documented
110//!   normalization/defaulting).
111//! - **AV-2 (required-key-strictness):** `from_attrs` fails iff
112//!   required keys for that view are missing.
113//! - **AV-3 (unknown-key-tolerance):** Unknown attrs keys must
114//!   not affect successful decode outcome. Concretization of
115//!   IA-6.
116//!
117//! ## Derive invariants (DP-*)
118//!
119//! - **DP-1 (derive-precedence):** `derive_properties` dispatches
120//!   on `status` first (DP-5), then `node_type`, then `error_code`,
121//!   then unknown. This order is the canonical detection chain.
122//! - **DP-2 (derive-totality-on-parse-failure):**
123//!   `derive_properties` is total; malformed or incoherent attrs
124//!   never panic and map to `NodeProperties::Error` with detail.
125//! - **DP-3 (derive-precedence-stability):**
126//!   `derive_properties` detection order is stable and explicit:
127//!   `status` > `node_type` > `error_code` > unknown.
128//! - **DP-4 (error-on-decode-failure):** Any view decode or
129//!   invariant failure maps to a deterministic
130//!   `NodeProperties::Error` with a `malformed_*` code family,
131//!   without panic.
132//! - **DP-5 (actor-view classification safety):** a payload
133//!   carrying the core `STATUS` key always decodes as `Actor`.
134//!   `STATUS` is set only by the blanket Actor builder
135//!   (`build_actor_attrs`) and is core-owned, so the actor-attrs
136//!   snapshot seam can neither remove nor override it (AS-2), and no
137//!   non-actor payload (root/host/proc/error) carries it. Therefore
138//!   an actor cannot inject `node_type`/`error_code` via the seam to
139//!   spoof a different node kind.
140//!
141//! ## Execution presentation (EX-*)
142//!
143//! These govern the `execution` field on `NodeProperties::Actor` — an
144//! actor's in-flight handler execution, reported through the generic
145//! actor-attrs snapshot seam (`AS-*` in `hyperactor::introspect`) and
146//! decoded from the `EXECUTION` attr in `derive_properties`.
147//!
148//! - **EX-1 (unsupported-vs-idle):** `execution: None` means the actor
149//!   does not report execution (no snapshot installed) -- *unsupported*,
150//!   not idle. A supported-but-idle actor is `Some` with
151//!   `active_count == 0`.
152//! - **EX-2 (partial-detail, never absence):** `complete == false`
153//!   means the per-handler detail was momentarily unavailable on that
154//!   read (e.g. a non-blocking tracker miss); `active_count` stays
155//!   authoritative and the field stays `Some` -- contention never
156//!   collapses `execution` to `None`.
157//! - **EX-3 (observational, not transactional):** `active_count` and
158//!   `active_handlers` are independent point-in-time reads;
159//!   `active_count` need not equal `sum(active_handlers[*].active_count)`
160//!   on a given poll (cf. IO-3). Consumers must not derive one from the
161//!   other.
162//! - **EX-4 (deterministic truncation):** `active_handlers` is ordered
163//!   oldest-first with a stable tie-break on `name`; `truncated == true`
164//!   means it is a prefix of the N oldest while `active_count` remains
165//!   the full total.
166//! - **EX-5 (post-mortem semantics):** a terminated actor's stored
167//!   snapshot persists its last `live_actor_payload`, so `execution`
168//!   reflects state *as of termination*. The producer drains in-flight
169//!   entries on stop (try/finally), so a stopped actor reports
170//!   `active_count == 0`.
171//!
172//! ## Inbound ordering presentation (IO-*)
173//!
174//! Mesh-admin presentation extension of the cross-crate `IO-*`
175//! family. The lower-level invariants `IO-1` (tri-state absence),
176//! `IO-2` (publish-time `try_lock`), and `IO-3` (no arithmetic
177//! relation between `queue_depth` and reorder-buffer depth) live in
178//! `hyperactor::introspect`. The presentation layer below adds:
179//!
180//! - **IO-4 (snapshot_complete derivation):**
181//!   `InboundOrdering.snapshot_complete ==
182//!   (skipped_session_count == 0)`. Mirrors
183//!   `OrderingSnapshot::is_complete()` at the presentation layer.
184//! - **IO-5 (known_session_count totality):**
185//!   `InboundOrdering.known_session_count ==
186//!   sessions.len() + skipped_session_count`. The only rollup
187//!   that is a true total across returned and skipped sessions.
188//! - **IO-6 (returned_* scope):**
189//!   `returned_buffered_session_count`,
190//!   `returned_buffered_message_count`, and
191//!   `returned_max_buffered_count` are computed over `sessions`
192//!   only and are LOWER BOUNDS when `snapshot_complete == false`.
193//! - **IO-7 (live-actor exposure):** For any actor built through
194//!   `Instance::new`, `/v1/{actor}` exposes
195//!   `inbound_ordering: Some(...)` -- never `None`. `None`
196//!   indicates either structural absence (test fixtures,
197//!   hand-built `InstanceCellState`) or a regression in the
198//!   publish path.
199//!
200//! ## py-spy integration (PS-*)
201//!
202//! - **PS-1 (target locality):** `PySpyDump` always targets
203//!   `std::process::id()` of the handling ProcAgent process. No
204//!   caller-supplied PID exists in the API.
205//! - **PS-2 (deterministic failure shape):** Execution failures are
206//!   classified into `BinaryNotFound { searched }` vs
207//!   `Failed { pid, binary, exit_code, stderr }`, never collapsed.
208//! - **PS-3 (binary resolution order):** Resolution order is exactly:
209//!   `PYSPY_BIN` config attr (if non-empty) then `"py-spy"` on PATH.
210//!   The attr is read via `hyperactor_config::global::get_cloned`;
211//!   env var `PYSPY_BIN` feeds in through the config layer.
212//!   If the first attempt is not found, the fallback attempt is
213//!   required.
214//! - **PS-4 (structured JSON output):** py-spy runs with `--json`;
215//!   output is parsed into `Vec<PySpyStackTrace>`. Parse failure
216//!   maps to `PySpyResult::Failed`.
217//! - **PS-5 (subprocess timeout):** `try_exec` bounds the py-spy
218//!   subprocess inside the worker to `MESH_ADMIN_PYSPY_TIMEOUT`
219//!   (default 10s). The budget is sized for `--native --native-all`
220//!   which unwinds native stacks via libunwind — significantly
221//!   slower than Python-only capture on loaded hosts. On expiry the
222//!   child is killed and reaped, and the worker returns
223//!   `Failed { stderr: "…timed out…" }`.
224//! - **PS-6 (bridge timeout):** The HTTP bridge uses a separate
225//!   `MESH_ADMIN_PYSPY_BRIDGE_TIMEOUT` (default 13s), which must
226//!   exceed `MESH_ADMIN_PYSPY_TIMEOUT` so the subprocess kill/reap
227//!   and reply can arrive before the bridge declares
228//!   `gateway_timeout`. Independent of
229//!   `MESH_ADMIN_SINGLE_HOST_TIMEOUT`.
230//! - **PS-7 (non-blocking delegation):** ProcAgent never awaits
231//!   py-spy execution inline. On `PySpyDump` it spawns a child
232//!   `PySpyWorker`, forwards the request, and returns immediately.
233//! - **PS-8 (worker lifecycle):** Each `PySpyWorker` handles
234//!   exactly one forwarded `RunPySpyDump`, replies directly to the
235//!   forwarded `OncePortRef`, then self-terminates via
236//!   `cx.stop()`. Clean exit, no supervision event.
237//! - **PS-9 (concurrent dumps):** py-spy is spawn-per-request, so
238//!   overlapping dumps on the same proc are allowed. Each worker
239//!   runs independently.
240//! - **PS-10 (nonblocking retry):** In nonblocking mode, `try_exec`
241//!   retries up to 3 times with 100ms backoff on failure, because
242//!   py-spy can segfault reading mutating process memory. All
243//!   attempts share a single deadline bounded by
244//!   `MESH_ADMIN_PYSPY_TIMEOUT` (PS-5).
245//! - **PS-11a (native-all-immediate-downgrade):** If py-spy rejects
246//!   `--native-all` with the recognized unsupported-flag signature
247//!   (exit code 2, stderr mentions `--native-all`), `try_exec`
248//!   retries immediately with `native_all = false` in the same outer
249//!   attempt.
250//! - **PS-11b (native-all-no-retry-consumption):** That downgrade
251//!   retry does not consume an outer nonblocking retry slot (PS-10)
252//!   and does not incur the 100ms inter-attempt backoff.
253//! - **PS-11c (native-all-downgrade-warning):** A successful
254//!   downgraded result includes the warning `"--native-all
255//!   unsupported by this py-spy; fell back to --native"`.
256//! - **PS-11d (native-all-failure-passthrough):** If the downgraded
257//!   retry also fails, the failure flows through the normal
258//!   nonblocking retry logic (PS-10) unchanged.
259//! - **PS-11e (native-all-sticky-downgrade):** Once the
260//!   unsupported-flag signature is detected,
261//!   `effective_opts.native_all` remains `false` for all subsequent
262//!   outer retries. The flag is not re-tested on later attempts.
263//! - **PS-12 (universal py-spy):** Worker procs and the service
264//!   proc can handle `PySpyDump`. Worker procs handle it via
265//!   ProcAgent; the service proc handles it via HostAgent (same
266//!   spawn-worker pattern). `pyspy_bridge` routes by proc name:
267//!   if `proc_id.base_name() == SERVICE_PROC_NAME`, the target
268//!   is `host_agent`; otherwise `proc_agent[0]`. Procs lacking
269//!   either agent (e.g. mesh-admin) fast-fail via PS-13.
270//! - **PS-13 (defensive probe):** Before sending `PySpyDump`,
271//!   `pyspy_bridge` probes the selected actor with an introspect
272//!   query bounded by `MESH_ADMIN_QUERY_CHILD_TIMEOUT` (default
273//!   100ms). Three outcomes: (a) probe reply arrives — proceed
274//!   with `PySpyDump`; (b) probe times out or recv closes —
275//!   return `not_found` (actor absent/unreachable); (c) probe
276//!   send itself fails — return `internal_error` (bridge-side
277//!   infrastructure failure). Cases (b) and (c) fast-fail
278//!   instead of waiting the full 13s
279//!   `MESH_ADMIN_PYSPY_BRIDGE_TIMEOUT`.
280//! - **PS-14 (reachability-based capability):** A proc supports
281//!   py-spy iff its stable handler actor is reachable: the
282//!   service proc requires a reachable `host_agent`; non-service
283//!   procs require a reachable `proc_agent[0]`. `PySpyWorker` is
284//!   transient per-request machinery (spawned on `PySpyDump`,
285//!   stopped after replying) and is not part of the reachability
286//!   contract.
287//!
288//! v1 contract notes:
289//! - The current py-spy bridge expects a ProcAddr-form reference and
290//!   rejects other forms as `bad_request`. This may be broadened in
291//!   future versions.
292//! - If `worker.send()` fails after the reply port has moved into
293//!   `RunPySpyDump`, the caller receives no explicit
294//!   `PySpyResult::Failed` — they observe a timeout.
295//!   `MailboxSenderError` does not carry the unsent message, so the
296//!   port is irrecoverable on this path.
297//! - **Contract change (D96756537 follow-up):** `PySpyResult::Ok`
298//!   replaced `stack: String` (raw py-spy text) with
299//!   `stack_traces: Vec<PySpyStackTrace>` (structured JSON) and
300//!   added `warnings: Vec<String>`. Clients reading the old `stack`
301//!   field will see it absent; they must migrate to `stack_traces`.
302//!
303//! ## py-spy profiling (PP-*)
304//!
305//! Profile capture (`py-spy record`) is a separate contract from
306//! dump (`py-spy dump`). Types, messages, workers, and routes are
307//! independent — no shared state, no shared timeout budget.
308//!
309//! - **PP-1 (input validation):** `duration_s` (u32) must be
310//!   non-zero and at most `MESH_ADMIN_PYSPY_MAX_PROFILE_DURATION`.
311//!   `rate_hz` must be 1..1000. Violations → 400 before any
312//!   actor messaging.
313//! - **PP-2 (dynamic timeout cascade):** Subprocess timeout =
314//!   `duration_s + 15s`. Bridge timeout = subprocess + 5s.
315//!   Computed per-request from validated opts, not static config.
316//! - **PP-3 (temp file lifecycle):** `py-spy record` writes to a
317//!   temp file; the worker reads it after successful exit and
318//!   deletes via tempfile drop. On failure or timeout, stderr is
319//!   captured. On timeout, the child is explicitly killed and
320//!   reaped via `start_kill()` + `wait().await`. If the file is
321//!   missing, empty, or unreadable after successful exit, the
322//!   result is `OutputMissing`, `OutputEmpty`, or
323//!   `OutputReadFailure`, not `Ok`.
324//! - **PP-4 (target locality):** Inherits PS-1 — always targets
325//!   `std::process::id()`, never a caller-supplied PID.
326//! - **PP-5 (separate worker):** `PySpyProfileWorker` is a
327//!   distinct actor from `PySpyWorker`. Profile durations block
328//!   for seconds to minutes; isolation prevents starving dumps.
329//! - **PP-6 (wire projection):** `ProfileExecOutcome` maps to
330//!   `PySpyProfileResult` 1:1 via `From`. Every internal variant
331//!   has an identically-named wire variant. The only shape change
332//!   is `TimedOut.timeout: Duration` → `TimedOut.timeout_s: u64`.
333//!
334//! ## Mesh-admin config (MA-*)
335//!
336//! - **MA-C1 (timeout config centralization):** Mesh-admin timeout
337//!   budgets are read from config attrs at call-time, with defaults
338//!   in `config.rs`. No hardcoded timeout constants in
339//!   `mesh_admin.rs`.
340
341pub mod dto;
342
343use hyperactor_config::AttrValue;
344use hyperactor_config::Attrs;
345use hyperactor_config::INTROSPECT;
346use hyperactor_config::IntrospectAttr;
347use hyperactor_config::declare_attrs;
348
349// See MK-1, MK-2, IA-1..IA-5 in module doc.
350declare_attrs! {
351    /// Topology role of this node: "root", "host", "proc", "error".
352    @meta(INTROSPECT = IntrospectAttr {
353        name: "node_type".into(),
354        desc: "Topology role: root, host, proc, error".into(),
355    })
356    pub attr NODE_TYPE: String;
357
358    /// Host network address (e.g. "10.0.0.1:8080").
359    @meta(INTROSPECT = IntrospectAttr {
360        name: "addr".into(),
361        desc: "Host network address".into(),
362    })
363    pub attr ADDR: String;
364
365    /// Number of procs on a host.
366    @meta(INTROSPECT = IntrospectAttr {
367        name: "num_procs".into(),
368        desc: "Number of procs on a host".into(),
369    })
370    pub attr NUM_PROCS: usize = 0;
371
372    /// Human-readable proc name.
373    @meta(INTROSPECT = IntrospectAttr {
374        name: "proc_name".into(),
375        desc: "Human-readable proc name".into(),
376    })
377    pub attr PROC_NAME: String;
378
379    /// Number of actors in a proc.
380    @meta(INTROSPECT = IntrospectAttr {
381        name: "num_actors".into(),
382        desc: "Number of actors in a proc".into(),
383    })
384    pub attr NUM_ACTORS: usize = 0;
385
386    /// References of system/infrastructure children.
387    @meta(INTROSPECT = IntrospectAttr {
388        name: "system_children".into(),
389        desc: "References of system/infrastructure children".into(),
390    })
391    pub attr SYSTEM_CHILDREN: Vec<NodeRef>;
392
393    /// References of stopped children (proc only).
394    @meta(INTROSPECT = IntrospectAttr {
395        name: "stopped_children".into(),
396        desc: "References of stopped children".into(),
397    })
398    pub attr STOPPED_CHILDREN: Vec<NodeRef>;
399
400    /// Cap on stopped children retention.
401    @meta(INTROSPECT = IntrospectAttr {
402        name: "stopped_retention_cap".into(),
403        desc: "Maximum number of stopped children retained".into(),
404    })
405    pub attr STOPPED_RETENTION_CAP: usize = 0;
406
407    /// Whether this proc is refusing new spawns due to actor
408    /// failures.
409    @meta(INTROSPECT = IntrospectAttr {
410        name: "is_poisoned".into(),
411        desc: "Whether this proc is poisoned (refusing new spawns)".into(),
412    })
413    pub attr IS_POISONED: bool = false;
414
415    /// Count of failed actors in a proc.
416    @meta(INTROSPECT = IntrospectAttr {
417        name: "failed_actor_count".into(),
418        desc: "Number of failed actors in this proc".into(),
419    })
420    pub attr FAILED_ACTOR_COUNT: usize = 0;
421
422    /// Timestamp when the mesh was started.
423    @meta(INTROSPECT = IntrospectAttr {
424        name: "started_at".into(),
425        desc: "Timestamp when the mesh was started".into(),
426    })
427    pub attr STARTED_AT: std::time::SystemTime;
428
429    /// Username who started the mesh.
430    @meta(INTROSPECT = IntrospectAttr {
431        name: "started_by".into(),
432        desc: "Username who started the mesh".into(),
433    })
434    pub attr STARTED_BY: String;
435
436    /// Number of hosts in the mesh (root only).
437    @meta(INTROSPECT = IntrospectAttr {
438        name: "num_hosts".into(),
439        desc: "Number of hosts in the mesh".into(),
440    })
441    pub attr NUM_HOSTS: usize = 0;
442
443    // ── Proc debug stats (PD-*) ──────────────────────────────
444
445    /// RSS of the hosting OS process (bytes). `None` means the
446    /// measurement was unavailable (for example non-Linux or procfs
447    /// read/parse failure); values are never fabricated (PD-2).
448    @meta(INTROSPECT = IntrospectAttr {
449        name: "process_rss_bytes".into(),
450        desc: "RSS of the hosting OS process (bytes)".into(),
451    })
452    pub attr PROCESS_RSS_BYTES: Option<u64>;
453
454    /// Virtual memory size of the hosting OS process (bytes). `None`
455    /// means the measurement was unavailable (for example non-Linux
456    /// or procfs read/parse failure); values are never fabricated
457    /// (PD-2).
458    @meta(INTROSPECT = IntrospectAttr {
459        name: "process_vm_size_bytes".into(),
460        desc: "Virtual memory size of the hosting OS process (bytes)".into(),
461    })
462    pub attr PROCESS_VM_SIZE_BYTES: Option<u64>;
463
464    /// Sum of per-actor message queue depths across live actors.
465    @meta(INTROSPECT = IntrospectAttr {
466        name: "actor_work_queue_depth_total".into(),
467        desc: "Sum of per-actor message queue depths (live actors only)".into(),
468    })
469    pub attr ACTOR_WORK_QUEUE_DEPTH_TOTAL: u64 = 0;
470
471    /// Maximum current per-actor message queue depth across live
472    /// actors at publish time. This is not a historical high-water
473    /// mark.
474    @meta(INTROSPECT = IntrospectAttr {
475        name: "actor_work_queue_depth_max".into(),
476        desc: "Maximum per-actor message queue depth (live actors only)".into(),
477    })
478    pub attr ACTOR_WORK_QUEUE_DEPTH_MAX: u64 = 0;
479
480    /// Maximum proc-wide queue depth observed since startup (PD-6).
481    /// Eventually consistent — concurrent readers may transiently
482    /// observe total > high_water_mark. Retained evidence — driven
483    /// from the runtime accounting path, not publish-time sampling.
484    @meta(INTROSPECT = IntrospectAttr {
485        name: "actor_work_queue_depth_high_water_mark".into(),
486        desc: "Maximum proc-wide queue depth since startup (eventually consistent)".into(),
487    })
488    pub attr ACTOR_WORK_QUEUE_DEPTH_HIGH_WATER_MARK: u64 = 0;
489
490    /// How long ago proc-wide queue depth was last observed non-zero
491    /// (PD-7). `None` means no counted actor work has traversed the
492    /// queue accounting path since startup. Uses wall clock, so the
493    /// age is best-effort telemetry and may not be strictly monotonic.
494    /// Retained evidence — driven from the runtime accounting path.
495    @meta(INTROSPECT = IntrospectAttr {
496        name: "last_nonzero_queue_depth_age_ms".into(),
497        desc: "Milliseconds since proc-wide queue depth was last observed non-zero (wall clock)".into(),
498    })
499    pub attr LAST_NONZERO_QUEUE_DEPTH_AGE_MS: Option<u64>;
500
501}
502
503use hyperactor::introspect::AttrsViewError;
504
505/// Typed view over attrs for a root node.
506#[derive(Debug, Clone, PartialEq)]
507pub struct RootAttrsView {
508    pub num_hosts: usize,
509    pub started_at: SystemTime,
510    pub started_by: String,
511    pub system_children: Vec<NodeRef>,
512}
513
514impl RootAttrsView {
515    /// Decode from an `Attrs` bag (AV-2, AV-3). Requires
516    /// `STARTED_AT` and `STARTED_BY`; `NUM_HOSTS` defaults to 0,
517    /// `SYSTEM_CHILDREN` defaults to empty.
518    pub fn from_attrs(attrs: &Attrs) -> Result<Self, AttrsViewError> {
519        let num_hosts = *attrs.get(NUM_HOSTS).unwrap_or(&0);
520        let started_at = *attrs
521            .get(STARTED_AT)
522            .ok_or_else(|| AttrsViewError::missing("started_at"))?;
523        let started_by = attrs
524            .get(STARTED_BY)
525            .ok_or_else(|| AttrsViewError::missing("started_by"))?
526            .clone();
527        let system_children = attrs.get(SYSTEM_CHILDREN).cloned().unwrap_or_default();
528        Ok(Self {
529            num_hosts,
530            started_at,
531            started_by,
532            system_children,
533        })
534    }
535
536    /// Encode into an `Attrs` bag (AV-1 round-trip producer).
537    pub fn to_attrs(&self) -> Attrs {
538        let mut attrs = Attrs::new();
539        attrs.set(NODE_TYPE, "root".to_string());
540        attrs.set(NUM_HOSTS, self.num_hosts);
541        attrs.set(STARTED_AT, self.started_at);
542        attrs.set(STARTED_BY, self.started_by.clone());
543        attrs.set(SYSTEM_CHILDREN, self.system_children.clone());
544        attrs
545    }
546}
547
548/// Memory stats of the hosting OS process. Shared by host and
549/// proc introspection surfaces — both agents are authoritative
550/// for the OS process they run in.
551///
552/// In the common one-proc-per-process deployment these read like
553/// "proc memory". In multi-proc-per-process setups, co-hosted procs
554/// report the same hosting-process values.
555#[derive(
556    Debug,
557    Clone,
558    Copy,
559    PartialEq,
560    Eq,
561    Default,
562    Serialize,
563    Deserialize,
564    Named
565)]
566pub struct ProcessMemoryStats {
567    /// RSS of the hosting OS process (bytes). `None` on non-Linux
568    /// or read failure (PD-2).
569    pub process_rss_bytes: Option<u64>,
570    /// Virtual memory size of the hosting OS process (bytes).
571    /// `None` on non-Linux or read failure (PD-2).
572    pub process_vm_size_bytes: Option<u64>,
573}
574
575impl ProcessMemoryStats {
576    /// Read the hosting OS process memory stats from procfs.
577    /// Returns `ProcessMemoryStats` with `None` fields on non-Linux
578    /// or any read/parse failure (PD-2: never fabricated).
579    pub fn read_from_procfs() -> Self {
580        let (rss, vm) = read_procfs_memory();
581        Self {
582            process_rss_bytes: rss,
583            process_vm_size_bytes: vm,
584        }
585    }
586
587    pub fn from_attrs(attrs: &Attrs) -> Self {
588        Self {
589            process_rss_bytes: attrs.get(PROCESS_RSS_BYTES).copied().flatten(),
590            process_vm_size_bytes: attrs.get(PROCESS_VM_SIZE_BYTES).copied().flatten(),
591        }
592    }
593
594    pub fn to_attrs(&self, attrs: &mut Attrs) {
595        attrs.set(PROCESS_RSS_BYTES, self.process_rss_bytes);
596        attrs.set(PROCESS_VM_SIZE_BYTES, self.process_vm_size_bytes);
597    }
598}
599
600/// Read RSS and VM size from `/proc/self/statm`.
601///
602/// `statm` field 0 is total program size (virtual memory) in pages;
603/// field 1 is resident set size in pages. This is sufficient for the
604/// Stage 1 operator signal and avoids parsing a larger procfs file.
605/// Returns `(Some(rss_bytes), Some(vm_bytes))` on success and `(None,
606/// None)` on any failure.
607#[cfg(target_os = "linux")]
608fn read_procfs_memory() -> (Option<u64>, Option<u64>) {
609    // SAFETY: sysconf(_SC_PAGESIZE) is a read-only query with no
610    // preconditions. It returns the system page size or -1 on error.
611    let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
612    if page_size <= 0 {
613        return (None, None);
614    }
615    let page_size = page_size as u64;
616    // Sync I/O is intentional even though callers may invoke this from
617    // async contexts. `/proc/self/statm` is in the O(1) procfs tier —
618    // the kernel formats values from `mm_struct` atomic counters
619    // maintained on the page-fault and exit paths, with no page-table
620    // walk; typical wall time is a few microseconds. Dispatching via
621    // `tokio::fs::read_to_string` would cost more than the read
622    // itself, and this call cannot block on real disk I/O.
623    match std::fs::read_to_string("/proc/self/statm") {
624        Ok(contents) => {
625            let mut fields = contents.split_whitespace();
626            let vm_pages: Option<u64> = fields.next().and_then(|s| s.parse().ok());
627            let rss_pages: Option<u64> = fields.next().and_then(|s| s.parse().ok());
628            (
629                rss_pages.map(|p| p * page_size),
630                vm_pages.map(|p| p * page_size),
631            )
632        }
633        Err(_) => (None, None),
634    }
635}
636
637#[cfg(not(target_os = "linux"))]
638fn read_procfs_memory() -> (Option<u64>, Option<u64>) {
639    (None, None)
640}
641
642/// Proc-level debug/operational stats. Groups hosting-process memory
643/// (process-scoped) and actor queue pressure (proc-scoped) into one
644/// operational summary.
645///
646/// This asymmetry is intentional: memory belongs to the hosting OS
647/// process, while queue pressure is aggregated over live actors in
648/// this Monarch proc only.
649///
650/// Queue depth is an **instantaneous snapshot** at publish time, not
651/// a historical watermark or backlog accumulator. It reflects
652/// currently queued work that has not yet been received by the
653/// actor's run loop. Transient bursts that drain between publishes
654/// will not be observed.
655#[derive(
656    Debug,
657    Clone,
658    Copy,
659    PartialEq,
660    Eq,
661    Default,
662    Serialize,
663    Deserialize,
664    Named
665)]
666pub struct ProcDebugStats {
667    /// Hosting-process memory (shared type with host surface).
668    pub memory: ProcessMemoryStats,
669    /// Sum of per-actor message queue depths across live actors in
670    /// this proc (PD-4: live actors only).
671    pub actor_work_queue_depth_total: u64,
672    /// Maximum current per-actor message queue depth across live
673    /// actors in this proc at publish time. Not a historical
674    /// high-water mark.
675    pub actor_work_queue_depth_max: u64,
676    /// Maximum proc-wide queue depth observed since startup (PD-6).
677    /// Eventually consistent — see PD-6 docs. Retained — driven
678    /// from the runtime accounting path.
679    pub actor_work_queue_depth_high_water_mark: u64,
680    /// How long ago proc-wide queue depth was last observed non-zero
681    /// (PD-7). `None` means never. Wall clock — see PD-9 docs.
682    /// Retained — driven from the runtime accounting path.
683    pub last_nonzero_queue_depth_age_ms: Option<u64>,
684}
685
686impl ProcDebugStats {
687    pub fn from_attrs(attrs: &Attrs) -> Self {
688        let total = attrs
689            .get(ACTOR_WORK_QUEUE_DEPTH_TOTAL)
690            .copied()
691            .unwrap_or(0);
692        let max = attrs.get(ACTOR_WORK_QUEUE_DEPTH_MAX).copied().unwrap_or(0);
693        // PD-1: max <= total.
694        if max > total {
695            tracing::warn!(
696                "PD-1 violation: actor_work_queue_depth_max ({}) > total ({})",
697                max,
698                total,
699            );
700        }
701        let high_water = attrs
702            .get(ACTOR_WORK_QUEUE_DEPTH_HIGH_WATER_MARK)
703            .copied()
704            .unwrap_or(0);
705        // PD-6: high_water_mark >= total eventually, but concurrent
706        // readers may transiently see total > high_water_mark (a
707        // sampling artifact, not an accounting error).
708        let last_nonzero = attrs
709            .get(LAST_NONZERO_QUEUE_DEPTH_AGE_MS)
710            .copied()
711            .flatten();
712        Self {
713            memory: ProcessMemoryStats::from_attrs(attrs),
714            actor_work_queue_depth_total: total,
715            actor_work_queue_depth_max: max,
716            actor_work_queue_depth_high_water_mark: high_water,
717            last_nonzero_queue_depth_age_ms: last_nonzero,
718        }
719    }
720
721    pub fn to_attrs(&self, attrs: &mut Attrs) {
722        self.memory.to_attrs(attrs);
723        attrs.set(
724            ACTOR_WORK_QUEUE_DEPTH_TOTAL,
725            self.actor_work_queue_depth_total,
726        );
727        attrs.set(ACTOR_WORK_QUEUE_DEPTH_MAX, self.actor_work_queue_depth_max);
728        attrs.set(
729            ACTOR_WORK_QUEUE_DEPTH_HIGH_WATER_MARK,
730            self.actor_work_queue_depth_high_water_mark,
731        );
732        attrs.set(
733            LAST_NONZERO_QUEUE_DEPTH_AGE_MS,
734            self.last_nonzero_queue_depth_age_ms,
735        );
736    }
737}
738
739/// Typed view over attrs for a host node.
740#[derive(Debug, Clone, PartialEq)]
741pub struct HostAttrsView {
742    pub addr: String,
743    pub num_procs: usize,
744    pub system_children: Vec<NodeRef>,
745    /// Hosting-process memory stats.
746    pub memory: ProcessMemoryStats,
747}
748
749impl HostAttrsView {
750    /// Decode from an `Attrs` bag (AV-2, AV-3). Requires `ADDR`;
751    /// `NUM_PROCS` defaults to 0, `SYSTEM_CHILDREN` defaults to
752    /// empty.
753    pub fn from_attrs(attrs: &Attrs) -> Result<Self, AttrsViewError> {
754        let addr = attrs
755            .get(ADDR)
756            .ok_or_else(|| AttrsViewError::missing("addr"))?
757            .clone();
758        let num_procs = *attrs.get(NUM_PROCS).unwrap_or(&0);
759        let system_children = attrs.get(SYSTEM_CHILDREN).cloned().unwrap_or_default();
760        let memory = ProcessMemoryStats::from_attrs(attrs);
761        Ok(Self {
762            addr,
763            num_procs,
764            system_children,
765            memory,
766        })
767    }
768
769    /// Encode into an `Attrs` bag (AV-1 round-trip producer).
770    pub fn to_attrs(&self) -> Attrs {
771        let mut attrs = Attrs::new();
772        attrs.set(NODE_TYPE, "host".to_string());
773        attrs.set(ADDR, self.addr.clone());
774        attrs.set(NUM_PROCS, self.num_procs);
775        attrs.set(SYSTEM_CHILDREN, self.system_children.clone());
776        self.memory.to_attrs(&mut attrs);
777        attrs
778    }
779}
780
781/// Typed view over attrs for a proc node.
782#[derive(Debug, Clone, PartialEq)]
783pub struct ProcAttrsView {
784    pub proc_name: String,
785    pub num_actors: usize,
786    pub system_children: Vec<NodeRef>,
787    pub stopped_children: Vec<NodeRef>,
788    pub stopped_retention_cap: usize,
789    pub is_poisoned: bool,
790    pub failed_actor_count: usize,
791    /// Runtime debug/operational stats (PD-*).
792    pub debug: ProcDebugStats,
793}
794
795impl ProcAttrsView {
796    /// Decode from an `Attrs` bag (AV-2, AV-3). Requires
797    /// `PROC_NAME`; remaining fields have defaults. Checks FI-5
798    /// coherence.
799    pub fn from_attrs(attrs: &Attrs) -> Result<Self, AttrsViewError> {
800        let proc_name = attrs
801            .get(PROC_NAME)
802            .ok_or_else(|| AttrsViewError::missing("proc_name"))?
803            .clone();
804        let num_actors = *attrs.get(NUM_ACTORS).unwrap_or(&0);
805        let system_children = attrs.get(SYSTEM_CHILDREN).cloned().unwrap_or_default();
806        let stopped_children = attrs.get(STOPPED_CHILDREN).cloned().unwrap_or_default();
807        let stopped_retention_cap = *attrs.get(STOPPED_RETENTION_CAP).unwrap_or(&0);
808        let is_poisoned = *attrs.get(IS_POISONED).unwrap_or(&false);
809        let failed_actor_count = *attrs.get(FAILED_ACTOR_COUNT).unwrap_or(&0);
810
811        // FI-5: is_poisoned iff failed_actor_count > 0.
812        if is_poisoned != (failed_actor_count > 0) {
813            return Err(AttrsViewError::invariant(
814                "FI-5",
815                format!("is_poisoned={is_poisoned} but failed_actor_count={failed_actor_count}"),
816            ));
817        }
818
819        let debug = ProcDebugStats::from_attrs(attrs);
820
821        Ok(Self {
822            proc_name,
823            num_actors,
824            system_children,
825            stopped_children,
826            stopped_retention_cap,
827            is_poisoned,
828            failed_actor_count,
829            debug,
830        })
831    }
832
833    /// Encode into an `Attrs` bag (AV-1 round-trip producer).
834    pub fn to_attrs(&self) -> Attrs {
835        let mut attrs = Attrs::new();
836        attrs.set(NODE_TYPE, "proc".to_string());
837        attrs.set(PROC_NAME, self.proc_name.clone());
838        attrs.set(NUM_ACTORS, self.num_actors);
839        attrs.set(SYSTEM_CHILDREN, self.system_children.clone());
840        attrs.set(STOPPED_CHILDREN, self.stopped_children.clone());
841        attrs.set(STOPPED_RETENTION_CAP, self.stopped_retention_cap);
842        attrs.set(IS_POISONED, self.is_poisoned);
843        attrs.set(FAILED_ACTOR_COUNT, self.failed_actor_count);
844        self.debug.to_attrs(&mut attrs);
845        attrs
846    }
847}
848
849/// Typed view over attrs for an error node.
850#[derive(Debug, Clone, PartialEq)]
851pub struct ErrorAttrsView {
852    pub code: String,
853    pub message: String,
854}
855
856impl ErrorAttrsView {
857    /// Decode from an `Attrs` bag (AV-2, AV-3). Requires
858    /// `ERROR_CODE`; `ERROR_MESSAGE` defaults to empty.
859    pub fn from_attrs(attrs: &Attrs) -> Result<Self, AttrsViewError> {
860        use hyperactor::introspect::ERROR_CODE;
861        use hyperactor::introspect::ERROR_MESSAGE;
862
863        let code = attrs
864            .get(ERROR_CODE)
865            .ok_or_else(|| AttrsViewError::missing("error_code"))?
866            .clone();
867        let message = attrs.get(ERROR_MESSAGE).cloned().unwrap_or_default();
868        Ok(Self { code, message })
869    }
870
871    /// Encode into an `Attrs` bag (AV-1 round-trip producer).
872    pub fn to_attrs(&self) -> Attrs {
873        use hyperactor::introspect::ERROR_CODE;
874        use hyperactor::introspect::ERROR_MESSAGE;
875
876        let mut attrs = Attrs::new();
877        attrs.set(ERROR_CODE, self.code.clone());
878        attrs.set(ERROR_MESSAGE, self.message.clone());
879        attrs
880    }
881}
882
883// --- API / presentation types ---
884
885use std::fmt;
886use std::str::FromStr;
887use std::time::SystemTime;
888
889use serde::Deserialize;
890use serde::Serialize;
891use typeuri::Named;
892
893/// Typed reference to a node in the mesh-admin navigation tree.
894///
895/// Extends `IntrospectRef` with mesh-only concepts (`Root`, `Host`).
896/// hyperactor does not know about these variants.
897#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Named)]
898pub enum NodeRef {
899    /// Synthetic mesh root node.
900    /// Serializes as lowercase `"root"` to match the HTTP path convention.
901    #[serde(rename = "root")]
902    Root,
903    /// A host in the mesh, identified by its `HostAgent` actor ID.
904    Host(hyperactor::ActorAddr),
905    /// A proc running on a host.
906    Proc(hyperactor::ProcAddr),
907    /// An actor instance within a proc.
908    Actor(hyperactor::ActorAddr),
909}
910
911hyperactor_config::impl_attrvalue!(NodeRef);
912
913impl fmt::Display for NodeRef {
914    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
915        match self {
916            Self::Root => write!(f, "root"),
917            Self::Host(id) => write!(f, "host:{}", id),
918            Self::Proc(id) => fmt::Display::fmt(id, f),
919            Self::Actor(id) => fmt::Display::fmt(id, f),
920        }
921    }
922}
923
924/// Error parsing a `NodeRef` from a string.
925#[derive(Debug, thiserror::Error)]
926pub enum NodeRefParseError {
927    #[error("empty reference string")]
928    Empty,
929    #[error("invalid host reference: {0}")]
930    InvalidHost(hyperactor::AddrParseError),
931    #[error("port references are not valid node references")]
932    PortNotAllowed,
933    #[error(transparent)]
934    Reference(#[from] hyperactor::AddrParseError),
935}
936
937impl FromStr for NodeRef {
938    type Err = NodeRefParseError;
939
940    fn from_str(s: &str) -> Result<Self, Self::Err> {
941        if s.is_empty() {
942            return Err(NodeRefParseError::Empty);
943        }
944        if s == "root" {
945            return Ok(Self::Root);
946        }
947        if let Some(rest) = s.strip_prefix("host:") {
948            let actor_id: hyperactor::ActorAddr =
949                rest.parse().map_err(NodeRefParseError::InvalidHost)?;
950            return Ok(Self::Host(actor_id));
951        }
952        let r: hyperactor::Addr = s.parse()?;
953        match r {
954            hyperactor::Addr::Proc(id) => Ok(Self::Proc(id)),
955            hyperactor::Addr::Actor(id) => Ok(Self::Actor(id)),
956            hyperactor::Addr::Port(_) => Err(NodeRefParseError::PortNotAllowed),
957        }
958    }
959}
960
961impl From<hyperactor::introspect::IntrospectRef> for NodeRef {
962    fn from(r: hyperactor::introspect::IntrospectRef) -> Self {
963        match r {
964            hyperactor::introspect::IntrospectRef::Proc(id) => Self::Proc(id),
965            hyperactor::introspect::IntrospectRef::Actor(id) => Self::Actor(id),
966        }
967    }
968}
969
970/// Uniform response for any node in the mesh topology.
971///
972/// Every addressable entity (root, host, proc, actor) is represented
973/// as a `NodePayload`. The client navigates the mesh by fetching a
974/// node and following its `children` references.
975///
976/// See IA-1..IA-5 in module doc.
977// Serialize/Deserialize required by wirevalue::register_type! and
978// ResolveReferenceResponse actor messaging. HTTP serialization uses
979// dto::NodePayloadDto, not these derives.
980#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
981pub struct NodePayload {
982    /// Canonical node reference identifying this node.
983    pub identity: NodeRef,
984    /// Node-specific metadata (type, status, metrics, etc.).
985    pub properties: NodeProperties,
986    /// Child node references for downward navigation.
987    pub children: Vec<NodeRef>,
988    /// Parent node reference for upward navigation.
989    pub parent: Option<NodeRef>,
990    /// When this payload was captured.
991    pub as_of: SystemTime,
992}
993wirevalue::register_type!(NodePayload);
994
995/// Node-specific metadata. Externally-tagged enum — the variant
996/// name is the discriminator (Root, Host, Proc, Actor, Error).
997// Serialize/Deserialize required by wirevalue::register_type! and
998// ResolveReferenceResponse actor messaging. HTTP serialization uses
999// dto::NodePropertiesDto, not these derives.
1000//
1001// `inbound_ordering` is boxed because the per-session detail can grow
1002// large, and every NodeProperties value is padded to the size of the
1003// biggest variant; boxing keeps the cost on the Actor heap when actually
1004// present rather than padding every Root/Host/Proc/Error value. The
1005// wire format is unchanged (serde transparent over Box<T>).
1006#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
1007pub enum NodeProperties {
1008    /// Synthetic mesh root node (not a real actor/proc).
1009    Root {
1010        num_hosts: usize,
1011        started_at: SystemTime,
1012        started_by: String,
1013        system_children: Vec<NodeRef>,
1014    },
1015    /// A host in the mesh, represented by its `HostAgent`.
1016    Host {
1017        addr: String,
1018        num_procs: usize,
1019        system_children: Vec<NodeRef>,
1020        memory: ProcessMemoryStats,
1021    },
1022    /// Properties describing a proc running on a host.
1023    Proc {
1024        proc_name: String,
1025        num_actors: usize,
1026        system_children: Vec<NodeRef>,
1027        stopped_children: Vec<NodeRef>,
1028        stopped_retention_cap: usize,
1029        is_poisoned: bool,
1030        failed_actor_count: usize,
1031        debug: ProcDebugStats,
1032    },
1033    /// Runtime metadata for a single actor instance.
1034    Actor {
1035        actor_status: String,
1036        actor_type: String,
1037        instance_id: String,
1038        messages_processed: u64,
1039        created_at: Option<SystemTime>,
1040        last_message_handler: Option<String>,
1041        total_processing_time_us: u64,
1042        queue_depth: u64,
1043        flight_recorder: Option<String>,
1044        is_system: bool,
1045        inbound_ordering: Option<Box<InboundOrdering>>,
1046        failure_info: Option<FailureInfo>,
1047        /// In-flight handler execution (EX-*). `None` means the actor
1048        /// does not report execution (unsupported), not idle.
1049        execution: Option<Box<Execution>>,
1050    },
1051    /// Error sentinel returned when a child reference cannot be resolved.
1052    Error { code: String, message: String },
1053}
1054wirevalue::register_type!(NodeProperties);
1055
1056/// Structured failure information for failed actors.
1057// Serialize/Deserialize required by wirevalue::register_type! and
1058// ResolveReferenceResponse actor messaging. HTTP serialization uses
1059// dto::FailureInfoDto, not these derives.
1060#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
1061pub struct FailureInfo {
1062    /// Error message describing the failure.
1063    pub error_message: String,
1064    /// Actor that caused the failure (root cause).
1065    pub root_cause_actor: hyperactor::ActorAddr,
1066    /// Display name of the root-cause actor, if available.
1067    pub root_cause_name: Option<String>,
1068    /// When the failure occurred.
1069    pub occurred_at: SystemTime,
1070    /// Whether this failure was propagated from a child.
1071    pub is_propagated: bool,
1072}
1073wirevalue::register_type!(FailureInfo);
1074
1075/// Mesh-admin presentation of inbound ordering state. Computed from
1076/// the upstream `hyperactor::ordering::OrderingSnapshot`; rollup fields
1077/// are derived at conversion time so consumers don't have to iterate
1078/// sessions for the common "is anything stalled?" question.
1079///
1080/// Partial-snapshot semantics (IO-2): when `snapshot_complete == false`,
1081/// `sessions` excludes any session held by a concurrent send. Rollups
1082/// marked "returned" below are computed over `sessions` only and are
1083/// LOWER BOUNDS in that case -- agents must refetch before concluding
1084/// "no stalls". `known_session_count` is the exception: it counts both
1085/// returned and skipped sessions.
1086//
1087// Serialize/Deserialize required by wirevalue::register_type! and
1088// ResolveReferenceResponse actor messaging. HTTP serialization uses
1089// dto::InboundOrderingDto, not these derives.
1090#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
1091pub struct InboundOrdering {
1092    /// Whether reorder buffering is enabled for this sender. When
1093    /// `false`, messages flow via `direct_send` and `sessions` is
1094    /// empty even under load.
1095    pub enabled: bool,
1096    /// IO-4: `true` iff `skipped_session_count == 0`. Mirrors
1097    /// `OrderingSnapshot::is_complete()`.
1098    pub snapshot_complete: bool,
1099    /// Sessions whose mutex was held by a concurrent send when we
1100    /// tried to snapshot. NOT in `sessions`.
1101    pub skipped_session_count: usize,
1102    /// IO-5: total live sessions known to `OrderedSender` at snapshot
1103    /// time: `sessions.len() + skipped_session_count`. Includes idle /
1104    /// drained sessions (state retained for duplicate-detection).
1105    pub known_session_count: usize,
1106    /// IO-6: sessions with `buffered_count > 0` AMONG RETURNED
1107    /// sessions. Lower bound if `!snapshot_complete`.
1108    pub returned_buffered_session_count: usize,
1109    /// IO-6: sum of `buffered_count` OVER RETURNED sessions.
1110    /// Reorder-buffer scope only (see IO-3 in `hyperactor::introspect`).
1111    /// Lower bound if `!snapshot_complete`.
1112    pub returned_buffered_message_count: usize,
1113    /// IO-6: max of `buffered_count` OVER RETURNED sessions. Lower
1114    /// bound if `!snapshot_complete`.
1115    pub returned_max_buffered_count: usize,
1116    /// Per-session entries, sorted by `session_id` (preserved from
1117    /// upstream sort). API returns all returned sessions; TUI may
1118    /// truncate.
1119    pub sessions: Vec<hyperactor::ordering::OrderingSessionSnapshot>,
1120}
1121wirevalue::register_type!(InboundOrdering);
1122
1123impl From<hyperactor::ordering::OrderingSnapshot> for InboundOrdering {
1124    fn from(s: hyperactor::ordering::OrderingSnapshot) -> Self {
1125        let snapshot_complete = s.skipped_session_count == 0;
1126        let returned_buffered_session_count =
1127            s.sessions.iter().filter(|x| x.buffered_count > 0).count();
1128        let returned_buffered_message_count: usize =
1129            s.sessions.iter().map(|x| x.buffered_count).sum();
1130        let returned_max_buffered_count = s
1131            .sessions
1132            .iter()
1133            .map(|x| x.buffered_count)
1134            .max()
1135            .unwrap_or(0);
1136        let known_session_count = s.sessions.len() + s.skipped_session_count;
1137        Self {
1138            enabled: s.enabled,
1139            snapshot_complete,
1140            skipped_session_count: s.skipped_session_count,
1141            known_session_count,
1142            returned_buffered_session_count,
1143            returned_buffered_message_count,
1144            returned_max_buffered_count,
1145            sessions: s.sessions,
1146        }
1147    }
1148}
1149
1150/// One handler with in-flight invocations, aggregated by name (EX-4).
1151// Serialize/Deserialize required for the `EXECUTION` attr and for
1152// `wirevalue` messaging via the enclosing `Execution`. HTTP
1153// serialization uses dto::ActiveHandlerDto, not these derives.
1154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
1155pub struct ActiveHandler {
1156    /// Handler name (e.g. a Python endpoint method name).
1157    pub name: String,
1158    /// In-flight invocations of this handler.
1159    pub active_count: u64,
1160    /// Start time of the oldest in-flight invocation of this handler.
1161    pub oldest_since: SystemTime,
1162}
1163
1164/// An actor's in-flight handler execution, reported through the generic
1165/// actor-attrs snapshot seam (`AS-*`). Carried both as the `EXECUTION`
1166/// attr value and as the `NodeProperties::Actor.execution` field; core
1167/// hyperactor does not interpret it. See EX-* in module doc.
1168// Serialize/Deserialize required by wirevalue::register_type! and the
1169// `EXECUTION` attr. HTTP serialization uses dto::ExecutionDto.
1170#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named, AttrValue)]
1171pub struct Execution {
1172    /// EX-2/EX-3: handler invocations currently in flight. Lock-free
1173    /// count, always present; need not equal
1174    /// `sum(active_handlers[*].active_count)`.
1175    pub active_count: u64,
1176    /// EX-4: per-handler detail, oldest-first; a prefix of the N oldest
1177    /// when `truncated`.
1178    pub active_handlers: Vec<ActiveHandler>,
1179    /// EX-2: `true` iff the per-handler detail was captured on this read.
1180    pub complete: bool,
1181    /// EX-4: `true` iff `active_handlers` is a prefix of the N oldest
1182    /// (`active_count` stays the full total).
1183    pub truncated: bool,
1184}
1185wirevalue::register_type!(Execution);
1186
1187impl fmt::Display for Execution {
1188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1189        write!(f, "{}", serde_json::to_string(self).unwrap())
1190    }
1191}
1192
1193impl FromStr for Execution {
1194    type Err = serde_json::Error;
1195    fn from_str(s: &str) -> Result<Self, Self::Err> {
1196        serde_json::from_str(s)
1197    }
1198}
1199
1200// The mesh-owned `EXECUTION` attr. Populated by a runtime via the
1201// generic seam (e.g. `monarch_hyperactor` for Python actors) and decoded
1202// in `derive_properties`; core hyperactor never interprets it.
1203declare_attrs! {
1204    /// In-flight handler execution for an actor.
1205    @meta(INTROSPECT = IntrospectAttr {
1206        name: "execution".into(),
1207        desc: "In-flight handler execution for an actor".into(),
1208    })
1209    pub attr EXECUTION: Execution;
1210}
1211
1212/// Mesh-layer conversion from a typed attrs view to `NodeProperties`.
1213///
1214/// Defined here so that `hyperactor` views (e.g. `ActorAttrsView`) can
1215/// produce `NodeProperties` without depending on the mesh crate.
1216trait IntoNodeProperties {
1217    fn into_node_properties(self) -> NodeProperties;
1218}
1219
1220impl IntoNodeProperties for RootAttrsView {
1221    fn into_node_properties(self) -> NodeProperties {
1222        NodeProperties::Root {
1223            num_hosts: self.num_hosts,
1224            started_at: self.started_at,
1225            started_by: self.started_by,
1226            system_children: self.system_children,
1227        }
1228    }
1229}
1230
1231impl IntoNodeProperties for HostAttrsView {
1232    fn into_node_properties(self) -> NodeProperties {
1233        NodeProperties::Host {
1234            addr: self.addr,
1235            num_procs: self.num_procs,
1236            system_children: self.system_children,
1237            memory: self.memory,
1238        }
1239    }
1240}
1241
1242impl IntoNodeProperties for ProcAttrsView {
1243    fn into_node_properties(self) -> NodeProperties {
1244        NodeProperties::Proc {
1245            proc_name: self.proc_name,
1246            num_actors: self.num_actors,
1247            system_children: self.system_children,
1248            stopped_children: self.stopped_children,
1249            stopped_retention_cap: self.stopped_retention_cap,
1250            is_poisoned: self.is_poisoned,
1251            failed_actor_count: self.failed_actor_count,
1252            debug: self.debug,
1253        }
1254    }
1255}
1256
1257impl IntoNodeProperties for ErrorAttrsView {
1258    fn into_node_properties(self) -> NodeProperties {
1259        NodeProperties::Error {
1260            code: self.code,
1261            message: self.message,
1262        }
1263    }
1264}
1265
1266impl IntoNodeProperties for hyperactor::introspect::ActorAttrsView {
1267    fn into_node_properties(self) -> NodeProperties {
1268        let actor_status = match &self.status_reason {
1269            Some(reason) => format!("{}: {}", self.status, reason),
1270            None => self.status.clone(),
1271        };
1272
1273        let failure_info = self.failure.map(|fi| FailureInfo {
1274            error_message: fi.error_message,
1275            root_cause_actor: fi.root_cause_actor,
1276            root_cause_name: fi.root_cause_name,
1277            occurred_at: fi.occurred_at,
1278            is_propagated: fi.is_propagated,
1279        });
1280
1281        NodeProperties::Actor {
1282            actor_status,
1283            actor_type: self.actor_type,
1284            instance_id: self.instance_id,
1285            messages_processed: self.messages_processed,
1286            created_at: self.created_at,
1287            last_message_handler: self.last_handler,
1288            total_processing_time_us: self.total_processing_time_us,
1289            queue_depth: self.queue_depth,
1290            flight_recorder: self.flight_recorder,
1291            is_system: self.is_system,
1292            inbound_ordering: self
1293                .inbound_ordering
1294                .map(|io| Box::new(InboundOrdering::from(io))),
1295            failure_info,
1296            // `ActorAttrsView` (core) is execution-agnostic; the mesh
1297            // decodes `execution` from the full attrs in
1298            // `derive_properties` (the seam keystone). Default to None.
1299            execution: None,
1300        }
1301    }
1302}
1303
1304/// Derive `NodeProperties` from a JSON-serialized attrs string.
1305///
1306/// Detection precedence (DP-1, DP-3, DP-5):
1307/// 1. `STATUS` key present → Actor (DP-5: a STATUS-bearing payload always
1308///    decodes as Actor, so the actor-attrs seam cannot spoof node kind)
1309/// 2. `node_type` = "root" / "host" / "proc" → corresponding variant
1310/// 3. `error_code` present → Error
1311/// 4. none of the above → Error("unknown_node_type")
1312///
1313/// DP-2 / DP-4: this function is total — malformed attrs never
1314/// panic; view decode failures map to `NodeProperties::Error`
1315/// with a `malformed_*` code.
1316/// AV-3 / IA-6: view decoders ignore unknown keys.
1317pub fn derive_properties(attrs_json: &str) -> NodeProperties {
1318    use hyperactor::introspect::ERROR_CODE;
1319    use hyperactor::introspect::STATUS;
1320
1321    let attrs: Attrs = match serde_json::from_str(attrs_json) {
1322        Ok(a) => a,
1323        Err(_) => {
1324            return NodeProperties::Error {
1325                code: "parse_error".into(),
1326                message: "failed to parse attrs JSON".into(),
1327            };
1328        }
1329    };
1330
1331    // DP-5 (actor-view classification safety): the core `STATUS` key is set
1332    // only by the blanket Actor builder (`build_actor_attrs`) and is
1333    // core-owned, so the actor-attrs snapshot seam can neither remove nor
1334    // override it (AS-2), and no non-actor payload carries it. Classifying
1335    // STATUS-present as `Actor` *before* `node_type`/`error_code` means a
1336    // snapshot-injected `node_type`/`error_code` cannot make a blanket actor
1337    // decode as Root/Proc/Error.
1338    if attrs.get(STATUS).is_some() {
1339        return match hyperactor::introspect::ActorAttrsView::from_attrs(&attrs) {
1340            Ok(v) => {
1341                // Keystone: `ActorAttrsView` (core) ignores the
1342                // mesh-owned `EXECUTION` key, so decode it here from the
1343                // full attrs and layer it onto the Actor node (EX-1:
1344                // absent → None).
1345                let mut props = v.into_node_properties();
1346                if let NodeProperties::Actor { execution, .. } = &mut props {
1347                    *execution = attrs.get(EXECUTION).cloned().map(Box::new);
1348                }
1349                props
1350            }
1351            Err(e) => NodeProperties::Error {
1352                code: "malformed_actor".into(),
1353                message: e.to_string(),
1354            },
1355        };
1356    }
1357
1358    let node_type = attrs.get(NODE_TYPE).cloned().unwrap_or_default();
1359
1360    match node_type.as_str() {
1361        "root" => match RootAttrsView::from_attrs(&attrs) {
1362            Ok(v) => v.into_node_properties(),
1363            Err(e) => NodeProperties::Error {
1364                code: "malformed_root".into(),
1365                message: e.to_string(),
1366            },
1367        },
1368        "host" => match HostAttrsView::from_attrs(&attrs) {
1369            Ok(v) => v.into_node_properties(),
1370            Err(e) => NodeProperties::Error {
1371                code: "malformed_host".into(),
1372                message: e.to_string(),
1373            },
1374        },
1375        "proc" => match ProcAttrsView::from_attrs(&attrs) {
1376            Ok(v) => v.into_node_properties(),
1377            Err(e) => NodeProperties::Error {
1378                code: "malformed_proc".into(),
1379                message: e.to_string(),
1380            },
1381        },
1382        _ => {
1383            // STATUS-bearing payloads decoded as Actor above (DP-5), so
1384            // here STATUS is absent: error_code → Error, else unknown.
1385            if attrs.get(ERROR_CODE).is_some() {
1386                return match ErrorAttrsView::from_attrs(&attrs) {
1387                    Ok(v) => v.into_node_properties(),
1388                    Err(e) => NodeProperties::Error {
1389                        code: "malformed_error".into(),
1390                        message: e.to_string(),
1391                    },
1392                };
1393            }
1394
1395            NodeProperties::Error {
1396                code: "unknown_node_type".into(),
1397                message: format!("unrecognized node_type: {:?}", node_type),
1398            }
1399        }
1400    }
1401}
1402
1403/// Convert an `IntrospectResult` to a presentation `NodePayload`.
1404/// Lifts `IntrospectRef` → `NodeRef` and passes through typed timestamps.
1405pub fn to_node_payload(result: hyperactor::introspect::IntrospectResult) -> NodePayload {
1406    NodePayload {
1407        identity: result.identity.into(),
1408        properties: derive_properties(&result.attrs),
1409        children: result.children.into_iter().map(NodeRef::from).collect(),
1410        parent: result.parent.map(NodeRef::from),
1411        as_of: result.as_of,
1412    }
1413}
1414
1415/// Convert an `IntrospectResult` to a `NodePayload`, overriding
1416/// identity and parent for correct tree navigation.
1417pub fn to_node_payload_with(
1418    result: hyperactor::introspect::IntrospectResult,
1419    identity: NodeRef,
1420    parent: Option<NodeRef>,
1421) -> NodePayload {
1422    NodePayload {
1423        identity,
1424        properties: derive_properties(&result.attrs),
1425        children: result.children.into_iter().map(NodeRef::from).collect(),
1426        parent,
1427        as_of: result.as_of,
1428    }
1429}
1430
1431#[cfg(test)]
1432mod tests {
1433    use super::*;
1434    use crate::mesh_id::ResourceId;
1435
1436    /// Enforces MK-1 (metadata completeness) for all mesh-topology
1437    /// introspection keys.
1438    #[test]
1439    fn test_mesh_introspect_keys_are_tagged() {
1440        let cases = vec![
1441            ("node_type", NODE_TYPE.attrs()),
1442            ("addr", ADDR.attrs()),
1443            ("num_procs", NUM_PROCS.attrs()),
1444            ("proc_name", PROC_NAME.attrs()),
1445            ("num_actors", NUM_ACTORS.attrs()),
1446            ("system_children", SYSTEM_CHILDREN.attrs()),
1447            ("stopped_children", STOPPED_CHILDREN.attrs()),
1448            ("stopped_retention_cap", STOPPED_RETENTION_CAP.attrs()),
1449            ("is_poisoned", IS_POISONED.attrs()),
1450            ("failed_actor_count", FAILED_ACTOR_COUNT.attrs()),
1451            ("started_at", STARTED_AT.attrs()),
1452            ("started_by", STARTED_BY.attrs()),
1453            ("num_hosts", NUM_HOSTS.attrs()),
1454            // PD-* proc debug stats keys.
1455            ("process_rss_bytes", PROCESS_RSS_BYTES.attrs()),
1456            ("process_vm_size_bytes", PROCESS_VM_SIZE_BYTES.attrs()),
1457            (
1458                "actor_work_queue_depth_total",
1459                ACTOR_WORK_QUEUE_DEPTH_TOTAL.attrs(),
1460            ),
1461            (
1462                "actor_work_queue_depth_max",
1463                ACTOR_WORK_QUEUE_DEPTH_MAX.attrs(),
1464            ),
1465            (
1466                "actor_work_queue_depth_high_water_mark",
1467                ACTOR_WORK_QUEUE_DEPTH_HIGH_WATER_MARK.attrs(),
1468            ),
1469            (
1470                "last_nonzero_queue_depth_age_ms",
1471                LAST_NONZERO_QUEUE_DEPTH_AGE_MS.attrs(),
1472            ),
1473            ("execution", EXECUTION.attrs()),
1474        ];
1475
1476        for (expected_name, meta) in &cases {
1477            // MK-1: every key must have INTROSPECT with non-empty
1478            // name and desc.
1479            let introspect = meta
1480                .get(INTROSPECT)
1481                .unwrap_or_else(|| panic!("{expected_name}: missing INTROSPECT meta-attr"));
1482            assert_eq!(
1483                introspect.name, *expected_name,
1484                "short name mismatch for {expected_name}"
1485            );
1486            assert!(
1487                !introspect.desc.is_empty(),
1488                "{expected_name}: desc should not be empty"
1489            );
1490        }
1491
1492        // Exhaustiveness: verify cases covers all INTROSPECT-tagged
1493        // keys declared in this module.
1494        use hyperactor_config::attrs::AttrKeyInfo;
1495        let registry_count = inventory::iter::<AttrKeyInfo>()
1496            .filter(|info| {
1497                info.name.starts_with("hyperactor_mesh::introspect::")
1498                    && info.meta.get(INTROSPECT).is_some()
1499            })
1500            .count();
1501        assert_eq!(
1502            cases.len(),
1503            registry_count,
1504            "test must cover all INTROSPECT-tagged keys in this module"
1505        );
1506    }
1507
1508    fn test_actor_ref(proc_name: &str, actor_name: &str) -> NodeRef {
1509        use hyperactor::channel::ChannelAddr;
1510
1511        NodeRef::Actor(
1512            ResourceId::proc_addr_from_name(ChannelAddr::Local(0), proc_name)
1513                .actor_addr(actor_name),
1514        )
1515    }
1516
1517    fn root_view() -> RootAttrsView {
1518        RootAttrsView {
1519            num_hosts: 3,
1520            started_at: std::time::UNIX_EPOCH,
1521            started_by: "testuser".into(),
1522            system_children: vec![test_actor_ref("proc", "child1")],
1523        }
1524    }
1525
1526    fn host_view() -> HostAttrsView {
1527        HostAttrsView {
1528            addr: "10.0.0.1:8080".into(),
1529            num_procs: 2,
1530            system_children: vec![test_actor_ref("proc", "sys")],
1531            memory: Default::default(),
1532        }
1533    }
1534
1535    fn proc_view() -> ProcAttrsView {
1536        ProcAttrsView {
1537            proc_name: "worker".into(),
1538            num_actors: 5,
1539            system_children: vec![],
1540            stopped_children: vec![test_actor_ref("proc", "old")],
1541            stopped_retention_cap: 10,
1542            is_poisoned: false,
1543            failed_actor_count: 0,
1544            debug: Default::default(),
1545        }
1546    }
1547
1548    fn error_view() -> ErrorAttrsView {
1549        ErrorAttrsView {
1550            code: "not_found".into(),
1551            message: "child not found".into(),
1552        }
1553    }
1554
1555    /// AV-1: from_attrs(to_attrs(v)) == v.
1556    #[test]
1557    fn test_root_view_round_trip() {
1558        let view = root_view();
1559        let rt = RootAttrsView::from_attrs(&view.to_attrs()).unwrap();
1560        assert_eq!(rt, view);
1561    }
1562
1563    /// AV-1.
1564    #[test]
1565    fn test_host_view_round_trip() {
1566        let view = host_view();
1567        let rt = HostAttrsView::from_attrs(&view.to_attrs()).unwrap();
1568        assert_eq!(rt, view);
1569    }
1570
1571    /// AV-1.
1572    #[test]
1573    fn test_proc_view_round_trip() {
1574        let view = proc_view();
1575        let rt = ProcAttrsView::from_attrs(&view.to_attrs()).unwrap();
1576        assert_eq!(rt, view);
1577    }
1578
1579    /// AV-1: host view with non-default memory round-trips.
1580    #[test]
1581    fn test_host_view_round_trip_with_memory() {
1582        let view = HostAttrsView {
1583            addr: "10.0.0.1:8080".into(),
1584            num_procs: 2,
1585            system_children: vec![],
1586            memory: ProcessMemoryStats {
1587                process_rss_bytes: Some(512 * 1024 * 1024),
1588                process_vm_size_bytes: Some(2 * 1024 * 1024 * 1024),
1589            },
1590        };
1591        let rt = HostAttrsView::from_attrs(&view.to_attrs()).unwrap();
1592        assert_eq!(rt, view);
1593    }
1594
1595    /// AV-1: proc view with non-default debug stats round-trips.
1596    #[test]
1597    fn test_proc_view_round_trip_with_debug() {
1598        let view = ProcAttrsView {
1599            proc_name: "worker".into(),
1600            num_actors: 5,
1601            system_children: vec![],
1602            stopped_children: vec![],
1603            stopped_retention_cap: 10,
1604            is_poisoned: false,
1605            failed_actor_count: 0,
1606            debug: ProcDebugStats {
1607                memory: ProcessMemoryStats {
1608                    process_rss_bytes: Some(256 * 1024 * 1024),
1609                    process_vm_size_bytes: Some(1024 * 1024 * 1024),
1610                },
1611                actor_work_queue_depth_total: 42,
1612                actor_work_queue_depth_max: 7,
1613                actor_work_queue_depth_high_water_mark: 100,
1614                last_nonzero_queue_depth_age_ms: Some(5000),
1615            },
1616        };
1617        let rt = ProcAttrsView::from_attrs(&view.to_attrs()).unwrap();
1618        assert_eq!(rt, view);
1619    }
1620
1621    /// PD-1: max <= total enforced (warning logged, no error).
1622    #[test]
1623    fn test_proc_debug_stats_pd1_warning_on_violation() {
1624        let mut attrs = Attrs::new();
1625        attrs.set(PROC_NAME, "test".to_string());
1626        attrs.set(ACTOR_WORK_QUEUE_DEPTH_TOTAL, 5u64);
1627        attrs.set(ACTOR_WORK_QUEUE_DEPTH_MAX, 10u64); // violation
1628        // Should not error, but should log warning.
1629        let view = ProcAttrsView::from_attrs(&attrs).unwrap();
1630        assert_eq!(view.debug.actor_work_queue_depth_total, 5);
1631        assert_eq!(view.debug.actor_work_queue_depth_max, 10);
1632    }
1633
1634    /// PD-3: missing debug attrs default to zero/None.
1635    #[test]
1636    fn test_proc_debug_stats_defaults_on_missing_attrs() {
1637        let mut attrs = Attrs::new();
1638        attrs.set(PROC_NAME, "old_proc".to_string());
1639        let view = ProcAttrsView::from_attrs(&attrs).unwrap();
1640        assert_eq!(view.debug, ProcDebugStats::default());
1641    }
1642
1643    /// AV-1.
1644    #[test]
1645    fn test_error_view_round_trip() {
1646        let view = error_view();
1647        let rt = ErrorAttrsView::from_attrs(&view.to_attrs()).unwrap();
1648        assert_eq!(rt, view);
1649    }
1650
1651    /// AV-2: missing required key rejected.
1652    #[test]
1653    fn test_root_view_missing_started_at() {
1654        let mut attrs = Attrs::new();
1655        attrs.set(NODE_TYPE, "root".into());
1656        attrs.set(STARTED_BY, "user".into());
1657        let err = RootAttrsView::from_attrs(&attrs).unwrap_err();
1658        assert_eq!(err, AttrsViewError::MissingKey { key: "started_at" });
1659    }
1660
1661    /// AV-2.
1662    #[test]
1663    fn test_root_view_missing_started_by() {
1664        let mut attrs = Attrs::new();
1665        attrs.set(NODE_TYPE, "root".into());
1666        attrs.set(STARTED_AT, std::time::UNIX_EPOCH);
1667        let err = RootAttrsView::from_attrs(&attrs).unwrap_err();
1668        assert_eq!(err, AttrsViewError::MissingKey { key: "started_by" });
1669    }
1670
1671    /// AV-2.
1672    #[test]
1673    fn test_host_view_missing_addr() {
1674        let attrs = Attrs::new();
1675        let err = HostAttrsView::from_attrs(&attrs).unwrap_err();
1676        assert_eq!(err, AttrsViewError::MissingKey { key: "addr" });
1677    }
1678
1679    /// AV-2.
1680    #[test]
1681    fn test_proc_view_missing_proc_name() {
1682        let attrs = Attrs::new();
1683        let err = ProcAttrsView::from_attrs(&attrs).unwrap_err();
1684        assert_eq!(err, AttrsViewError::MissingKey { key: "proc_name" });
1685    }
1686
1687    /// FI-5: poisoned without failures rejected.
1688    #[test]
1689    fn test_proc_view_fi5_poisoned_but_no_failures() {
1690        let mut attrs = Attrs::new();
1691        attrs.set(PROC_NAME, "bad".into());
1692        attrs.set(IS_POISONED, true);
1693        attrs.set(FAILED_ACTOR_COUNT, 0usize);
1694        let err = ProcAttrsView::from_attrs(&attrs).unwrap_err();
1695        assert!(matches!(
1696            err,
1697            AttrsViewError::InvariantViolation { label: "FI-5", .. }
1698        ));
1699    }
1700
1701    /// FI-5: failures without poisoned rejected.
1702    #[test]
1703    fn test_proc_view_fi5_failures_but_not_poisoned() {
1704        let mut attrs = Attrs::new();
1705        attrs.set(PROC_NAME, "bad".into());
1706        attrs.set(IS_POISONED, false);
1707        attrs.set(FAILED_ACTOR_COUNT, 2usize);
1708        let err = ProcAttrsView::from_attrs(&attrs).unwrap_err();
1709        assert!(matches!(
1710            err,
1711            AttrsViewError::InvariantViolation { label: "FI-5", .. }
1712        ));
1713    }
1714
1715    /// DP-2 / DP-4: unparseable JSON → Error.
1716    #[test]
1717    fn test_derive_properties_unparseable_json() {
1718        let props = derive_properties("not json");
1719        assert!(matches!(props, NodeProperties::Error { code, .. } if code == "parse_error"));
1720    }
1721
1722    /// DP-3: unknown node_type → Error.
1723    #[test]
1724    fn test_derive_properties_unknown_node_type() {
1725        let attrs = Attrs::new();
1726        let json = serde_json::to_string(&attrs).unwrap();
1727        let props = derive_properties(&json);
1728        assert!(matches!(props, NodeProperties::Error { code, .. } if code == "unknown_node_type"));
1729    }
1730
1731    /// DP-4: view decode failure → malformed_* Error.
1732    #[test]
1733    fn test_derive_properties_malformed_root() {
1734        let mut attrs = Attrs::new();
1735        attrs.set(NODE_TYPE, "root".into());
1736        let json = serde_json::to_string(&attrs).unwrap();
1737        let props = derive_properties(&json);
1738        assert!(matches!(props, NodeProperties::Error { code, .. } if code == "malformed_root"));
1739    }
1740
1741    /// DP-4: invariant violation → malformed_* Error.
1742    #[test]
1743    fn test_derive_properties_malformed_proc_fi5() {
1744        let mut attrs = Attrs::new();
1745        attrs.set(NODE_TYPE, "proc".into());
1746        attrs.set(PROC_NAME, "bad".into());
1747        attrs.set(IS_POISONED, true);
1748        attrs.set(FAILED_ACTOR_COUNT, 0usize);
1749        let json = serde_json::to_string(&attrs).unwrap();
1750        let props = derive_properties(&json);
1751        assert!(matches!(props, NodeProperties::Error { code, .. } if code == "malformed_proc"));
1752    }
1753
1754    /// DP-3: node_type "root" → Root variant.
1755    #[test]
1756    fn test_derive_properties_valid_root() {
1757        let view = root_view();
1758        let json = serde_json::to_string(&view.to_attrs()).unwrap();
1759        let props = derive_properties(&json);
1760        assert!(matches!(props, NodeProperties::Root { num_hosts: 3, .. }));
1761    }
1762
1763    /// DP-3: node_type "host" → Host variant.
1764    #[test]
1765    fn test_derive_properties_valid_host() {
1766        let view = host_view();
1767        let json = serde_json::to_string(&view.to_attrs()).unwrap();
1768        let props = derive_properties(&json);
1769        assert!(matches!(props, NodeProperties::Host { num_procs: 2, .. }));
1770    }
1771
1772    /// DP-3: node_type "proc" → Proc variant.
1773    #[test]
1774    fn test_derive_properties_valid_proc() {
1775        let view = proc_view();
1776        let json = serde_json::to_string(&view.to_attrs()).unwrap();
1777        let props = derive_properties(&json);
1778        assert!(matches!(props, NodeProperties::Proc { num_actors: 5, .. }));
1779    }
1780
1781    /// DP-3: error_code present → Error variant.
1782    #[test]
1783    fn test_derive_properties_valid_error() {
1784        let view = error_view();
1785        let json = serde_json::to_string(&view.to_attrs()).unwrap();
1786        let props = derive_properties(&json);
1787        assert!(matches!(props, NodeProperties::Error { .. }));
1788        if let NodeProperties::Error { code, message } = props {
1789            assert_eq!(code, "not_found");
1790            assert_eq!(message, "child not found");
1791        }
1792    }
1793
1794    /// DP-3: STATUS present → Actor variant.
1795    #[test]
1796    fn test_derive_properties_valid_actor() {
1797        use hyperactor::introspect::ACTOR_TYPE;
1798        use hyperactor::introspect::INSTANCE_ID;
1799        use hyperactor::introspect::MESSAGES_PROCESSED;
1800        use hyperactor::introspect::STATUS;
1801
1802        let mut attrs = Attrs::new();
1803        attrs.set(STATUS, "running".into());
1804        attrs.set(ACTOR_TYPE, "TestActor".into());
1805        attrs.set(INSTANCE_ID, "01900000-0000-7000-8000-000000000001".into());
1806        attrs.set(MESSAGES_PROCESSED, 7u64);
1807        let json = serde_json::to_string(&attrs).unwrap();
1808        let props = derive_properties(&json);
1809        assert!(matches!(
1810            props,
1811            NodeProperties::Actor {
1812                messages_processed: 7,
1813                ..
1814            }
1815        ));
1816    }
1817
1818    /// DP-5: a snapshot-injected `node_type` cannot make a STATUS-bearing
1819    /// (blanket) actor decode as a non-Actor node.
1820    #[test]
1821    fn test_derive_properties_status_first_ignores_injected_node_type() {
1822        use hyperactor::introspect::ACTOR_TYPE;
1823        use hyperactor::introspect::INSTANCE_ID;
1824        use hyperactor::introspect::STATUS;
1825
1826        let mut attrs = Attrs::new();
1827        attrs.set(STATUS, "running".into());
1828        attrs.set(ACTOR_TYPE, "TestActor".into());
1829        attrs.set(INSTANCE_ID, "01900000-0000-7000-8000-000000000001".into());
1830        // Hostile injection via the actor-attrs seam.
1831        attrs.set(NODE_TYPE, "root".into());
1832        let json = serde_json::to_string(&attrs).unwrap();
1833        assert!(matches!(
1834            derive_properties(&json),
1835            NodeProperties::Actor { .. }
1836        ));
1837    }
1838
1839    /// DP-5: a snapshot-injected `error_code` cannot make a STATUS-bearing
1840    /// actor decode as an Error node.
1841    #[test]
1842    fn test_derive_properties_status_first_ignores_injected_error_code() {
1843        use hyperactor::introspect::ACTOR_TYPE;
1844        use hyperactor::introspect::ERROR_CODE;
1845        use hyperactor::introspect::INSTANCE_ID;
1846        use hyperactor::introspect::STATUS;
1847
1848        let mut attrs = Attrs::new();
1849        attrs.set(STATUS, "running".into());
1850        attrs.set(ACTOR_TYPE, "TestActor".into());
1851        attrs.set(INSTANCE_ID, "01900000-0000-7000-8000-000000000001".into());
1852        // Hostile injection via the actor-attrs seam.
1853        attrs.set(ERROR_CODE, "not_found".into());
1854        let json = serde_json::to_string(&attrs).unwrap();
1855        assert!(matches!(
1856            derive_properties(&json),
1857            NodeProperties::Actor { .. }
1858        ));
1859    }
1860
1861    /// Injects an unknown key into serialized attrs JSON and
1862    /// verifies that derive_properties still decodes successfully.
1863    /// Exercises IA-6 (open-row-forward-compat) for each view.
1864    fn inject_unknown_key(attrs: &Attrs) -> String {
1865        let mut map: serde_json::Map<String, serde_json::Value> =
1866            serde_json::from_str(&serde_json::to_string(attrs).unwrap()).unwrap();
1867        map.insert(
1868            "unknown_future_key".into(),
1869            serde_json::Value::String("surprise".into()),
1870        );
1871        serde_json::to_string(&map).unwrap()
1872    }
1873
1874    #[test]
1875    fn test_ia6_root_ignores_unknown_keys() {
1876        let json = inject_unknown_key(&root_view().to_attrs());
1877        let props = derive_properties(&json);
1878        assert!(matches!(props, NodeProperties::Root { num_hosts: 3, .. }));
1879    }
1880
1881    #[test]
1882    fn test_ia6_host_ignores_unknown_keys() {
1883        let json = inject_unknown_key(&host_view().to_attrs());
1884        let props = derive_properties(&json);
1885        assert!(matches!(props, NodeProperties::Host { num_procs: 2, .. }));
1886    }
1887
1888    #[test]
1889    fn test_ia6_proc_ignores_unknown_keys() {
1890        let json = inject_unknown_key(&proc_view().to_attrs());
1891        let props = derive_properties(&json);
1892        assert!(matches!(props, NodeProperties::Proc { num_actors: 5, .. }));
1893    }
1894
1895    #[test]
1896    fn test_ia6_error_ignores_unknown_keys() {
1897        let json = inject_unknown_key(&error_view().to_attrs());
1898        let props = derive_properties(&json);
1899        assert!(matches!(props, NodeProperties::Error { .. }));
1900    }
1901
1902    #[test]
1903    fn test_ia6_actor_ignores_unknown_keys() {
1904        use hyperactor::introspect::ACTOR_TYPE;
1905        use hyperactor::introspect::INSTANCE_ID;
1906        use hyperactor::introspect::STATUS;
1907
1908        let mut attrs = Attrs::new();
1909        attrs.set(STATUS, "running".into());
1910        attrs.set(ACTOR_TYPE, "TestActor".into());
1911        attrs.set(INSTANCE_ID, "01900000-0000-7000-8000-000000000001".into());
1912        let json = inject_unknown_key(&attrs);
1913        let props = derive_properties(&json);
1914        assert!(matches!(props, NodeProperties::Actor { .. }));
1915    }
1916
1917    /// SC-1 / SC-2: schema is derived from types and matches the
1918    /// checked-in snapshot.
1919    ///
1920    /// To update after intentional type changes:
1921    /// ```sh
1922    /// buck run fbcode//monarch/hyperactor_mesh:generate_api_artifacts \
1923    ///   @fbcode//mode/dev-nosan -- \
1924    ///   fbcode/monarch/hyperactor_mesh/src/testdata
1925    /// ```
1926    /// Strip the `$comment` field (containing the @\u{200B}generated marker)
1927    /// from a JSON value so snapshot comparisons ignore it.
1928    fn strip_comment(mut value: serde_json::Value) -> serde_json::Value {
1929        if let Some(obj) = value.as_object_mut() {
1930            obj.remove("$comment");
1931        }
1932        value
1933    }
1934
1935    #[test]
1936    fn test_node_payload_schema_snapshot() {
1937        let schema = schemars::schema_for!(dto::NodePayloadDto);
1938        let actual: serde_json::Value = serde_json::to_value(&schema).unwrap();
1939        let expected: serde_json::Value = strip_comment(
1940            serde_json::from_str(include_str!("testdata/node_payload_schema.json"))
1941                .expect("snapshot must be valid JSON"),
1942        );
1943        assert_eq!(
1944            actual, expected,
1945            "schema changed — review and update snapshot if intentional"
1946        );
1947    }
1948
1949    /// SC-3: real payloads validate against the generated schema.
1950    #[test]
1951    fn test_payloads_validate_against_schema() {
1952        use hyperactor::channel::ChannelAddr;
1953
1954        let schema = schemars::schema_for!(dto::NodePayloadDto);
1955        let schema_value = serde_json::to_value(&schema).unwrap();
1956        let compiled = jsonschema::JSONSchema::compile(&schema_value).expect("schema must compile");
1957
1958        let epoch = std::time::UNIX_EPOCH;
1959        let proc_id = ResourceId::proc_addr_from_name(ChannelAddr::Local(0), "worker");
1960        let actor_id = proc_id.actor_addr("actor");
1961
1962        let samples = [
1963            NodePayload {
1964                identity: NodeRef::Root,
1965                properties: NodeProperties::Root {
1966                    num_hosts: 2,
1967                    started_at: epoch,
1968                    started_by: "testuser".into(),
1969                    system_children: vec![],
1970                },
1971                children: vec![NodeRef::Host(actor_id.clone())],
1972                parent: None,
1973                as_of: epoch,
1974            },
1975            NodePayload {
1976                identity: NodeRef::Host(actor_id.clone()),
1977                properties: NodeProperties::Host {
1978                    addr: "10.0.0.1:8080".into(),
1979                    num_procs: 2,
1980                    system_children: vec![test_actor_ref("proc", "sys")],
1981                    memory: Default::default(),
1982                },
1983                children: vec![NodeRef::Proc(proc_id.clone())],
1984                parent: Some(NodeRef::Root),
1985                as_of: epoch,
1986            },
1987            NodePayload {
1988                identity: NodeRef::Proc(proc_id.clone()),
1989                properties: NodeProperties::Proc {
1990                    proc_name: "worker".into(),
1991                    num_actors: 5,
1992                    system_children: vec![],
1993                    stopped_children: vec![],
1994                    stopped_retention_cap: 10,
1995                    is_poisoned: false,
1996                    failed_actor_count: 0,
1997                    debug: Default::default(),
1998                },
1999                children: vec![NodeRef::Actor(actor_id.clone())],
2000                parent: Some(NodeRef::Host(actor_id.clone())),
2001                as_of: epoch,
2002            },
2003            NodePayload {
2004                identity: NodeRef::Actor(actor_id.clone()),
2005                properties: NodeProperties::Actor {
2006                    actor_status: "running".into(),
2007                    actor_type: "MyActor".into(),
2008                    instance_id: "01900000-0000-7000-8000-000000000001".into(),
2009                    messages_processed: 42,
2010                    created_at: Some(epoch),
2011                    last_message_handler: Some("handle_ping".into()),
2012                    total_processing_time_us: 1000,
2013                    queue_depth: 0,
2014                    flight_recorder: None,
2015                    is_system: false,
2016                    inbound_ordering: None,
2017                    failure_info: None,
2018                    execution: None,
2019                },
2020                children: vec![],
2021                parent: Some(NodeRef::Proc(proc_id.clone())),
2022                as_of: epoch,
2023            },
2024            NodePayload {
2025                identity: NodeRef::Actor(actor_id.clone()),
2026                properties: NodeProperties::Error {
2027                    code: "not_found".into(),
2028                    message: "child not found".into(),
2029                },
2030                children: vec![],
2031                parent: None,
2032                as_of: epoch,
2033            },
2034        ];
2035
2036        for (i, payload) in samples.iter().enumerate() {
2037            let dto = dto::NodePayloadDto::from(payload.clone());
2038            let value = serde_json::to_value(&dto).unwrap();
2039            assert!(
2040                compiled.is_valid(&value),
2041                "sample {i} failed schema validation"
2042            );
2043        }
2044    }
2045
2046    /// SC-4: `$id` is injected only at the serve boundary.
2047    /// Stripping `$id` from the served schema must yield the raw
2048    /// schemars output.
2049    #[test]
2050    fn test_served_schema_is_raw_plus_id() {
2051        let raw: serde_json::Value =
2052            serde_json::to_value(schemars::schema_for!(dto::NodePayloadDto)).unwrap();
2053
2054        // Simulate what the endpoint does.
2055        let mut served = raw.clone();
2056        served.as_object_mut().unwrap().insert(
2057            "$id".into(),
2058            serde_json::Value::String("https://monarch.meta.com/schemas/v1/node_payload".into()),
2059        );
2060
2061        // Strip $id — remainder must equal raw.
2062        let mut stripped = served;
2063        stripped.as_object_mut().unwrap().remove("$id");
2064        assert_eq!(raw, stripped, "served schema differs from raw beyond $id");
2065    }
2066
2067    /// SC-2: error envelope schema matches checked-in snapshot.
2068    #[test]
2069    fn test_error_schema_snapshot() {
2070        use crate::mesh_admin::ApiErrorEnvelope;
2071
2072        let schema = schemars::schema_for!(ApiErrorEnvelope);
2073        let actual: serde_json::Value = serde_json::to_value(&schema).unwrap();
2074        let expected: serde_json::Value = strip_comment(
2075            serde_json::from_str(include_str!("testdata/error_schema.json"))
2076                .expect("error snapshot must be valid JSON"),
2077        );
2078        assert_eq!(
2079            actual, expected,
2080            "error schema changed — review and update snapshot if intentional"
2081        );
2082    }
2083
2084    /// SC-2: AdminInfo schema matches checked-in snapshot.
2085    #[test]
2086    fn test_admin_info_schema_snapshot() {
2087        use crate::mesh_admin::AdminInfo;
2088
2089        let schema = schemars::schema_for!(AdminInfo);
2090        let actual: serde_json::Value = serde_json::to_value(&schema).unwrap();
2091        let expected: serde_json::Value = strip_comment(
2092            serde_json::from_str(include_str!("testdata/admin_info_schema.json"))
2093                .expect("admin info snapshot must be valid JSON"),
2094        );
2095        assert_eq!(
2096            actual, expected,
2097            "AdminInfo schema changed — review and update snapshot if intentional"
2098        );
2099    }
2100
2101    /// SC-2: OpenAPI spec matches checked-in snapshot.
2102    #[test]
2103    fn test_openapi_spec_snapshot() {
2104        let actual = crate::mesh_admin::build_openapi_spec();
2105        let expected: serde_json::Value = strip_comment(
2106            serde_json::from_str(include_str!("testdata/openapi.json"))
2107                .expect("OpenAPI snapshot must be valid JSON"),
2108        );
2109        assert_eq!(
2110            actual, expected,
2111            "OpenAPI spec changed — review and update snapshot if intentional"
2112        );
2113    }
2114}