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