Skip to main content

hyperactor_mesh/
mesh_admin.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-level admin surface for topology introspection and reference
10//! walking.
11//!
12//! This module defines `MeshAdminAgent`, an actor that exposes a
13//! uniform, reference-based HTTP API over an entire host mesh. Every
14//! addressable entity in the mesh is represented as a `NodePayload`
15//! and resolved via typed `NodeRef` references (parsed from HTTP
16//! path strings at the request boundary).
17//!
18//! Incoming HTTP requests are bridged into the actor message loop
19//! using `ResolveReferenceMessage`, ensuring that all topology
20//! resolution and data collection happens through actor messaging.
21//! The agent fans out to `HostAgent` instances to fetch host,
22//! proc, and actor details, then normalizes them into a single
23//! tree-shaped model (`NodeProperties` + children references)
24//! suitable for topology-agnostic clients such as the admin TUI.
25//!
26//! # Schema strategy
27//!
28//! The external API contract is schema-first: the JSON Schema
29//! (Draft 2020-12) served at `GET /v1/schema` is the
30//! authoritative definition of the response shape. The error
31//! envelope schema is at `GET /v1/schema/error`.
32//!
33//! Schema and OpenAPI are derived from the HTTP boundary DTO types
34//! in [`crate::introspect::dto`] (`NodePayloadDto`,
35//! `NodePropertiesDto`, `FailureInfoDto`) via
36//! `schemars::JsonSchema`. The domain types (`NodePayload`,
37//! `NodeProperties`, `FailureInfo`) do not carry `JsonSchema` —
38//! they own the typed internal model; the DTOs own the wire
39//! contract.
40//!
41//! This follows the "Admin Gateway Pattern" RFC
42//! ([doc](https://fburl.com/1dvah88uutaiyesebojouen2)):
43//! schema is the product; transports and tooling are projections.
44//!
45//! ## Schema generation pipeline
46//!
47//! 1. `#[derive(JsonSchema)]` on `NodePayloadDto`,
48//!    `NodePropertiesDto`, `FailureInfoDto`, `ApiError`,
49//!    `ApiErrorEnvelope`.
50//! 2. `schemars::schema_for!(T)` produces a `Schema` value at
51//!    runtime (Draft 2020-12).
52//! 3. The `serve_schema` / `serve_error_schema` handlers inject a
53//!    `$id` field (SC-4) and serve the result as JSON.
54//! 4. Snapshot tests in `introspect::tests` compare the raw
55//!    schemars output (without `$id`) against checked-in golden
56//!    files to detect drift (SC-2).
57//! 5. Validation tests construct domain payloads, convert to DTOs,
58//!    and confirm the serialized DTOs pass schema validation
59//!    (SC-3).
60//!
61//! ## Regenerating snapshots
62//!
63//! After intentional changes to the DTO types
64//! (`NodePayloadDto`, `NodePropertiesDto`, `FailureInfoDto`),
65//! `ApiError`, or `ApiErrorEnvelope`, regenerate the golden
66//! files:
67//!
68//! ```sh
69//! buck run fbcode//monarch/hyperactor_mesh:generate_api_artifacts \
70//!   @fbcode//mode/dev-nosan -- \
71//!   fbcode/monarch/hyperactor_mesh/src/testdata
72//! ```
73//!
74//! Or via cargo:
75//!
76//! ```sh
77//! cargo run -p hyperactor_mesh --bin generate_api_artifacts -- \
78//!   hyperactor_mesh/src/testdata
79//! ```
80//!
81//! Then re-run tests to confirm the new snapshot passes.
82//!
83//! ## Schema invariants (SC-*)
84//!
85//! - **SC-1 (schema-derived):** Schema is derived from the DTO
86//!   types via `schemars::JsonSchema`, not hand-written.
87//! - **SC-2 (schema-snapshot-stability):** Schema changes must
88//!   be explicit — a snapshot test catches unintentional drift.
89//! - **SC-3 (schema-payload-conformance):** Domain payloads
90//!   converted to DTOs validate against the generated schema.
91//! - **SC-4 (schema-version-identity):** Served schemas carry a
92//!   `$id` tied to the API version (e.g.
93//!   `https://monarch.meta.com/schemas/v1/node_payload`).
94//! - **SC-5 (route-precedence):** Literal schema routes are
95//!   matched by specificity before the `{*reference}` wildcard
96//!   (axum 0.8 specificity-based routing).
97//!
98//! Note on `ApiError.details`: the derived schema is maximally
99//! permissive for `details` (any valid JSON). This is intentional
100//! for v1 — `details` is a domain-specific escape hatch.
101//! Consumers must not assume a fixed shape.
102//!
103//! # Introspection visibility policy
104//!
105//! Admin tooling only displays **introspectable** nodes: entities
106//! that are reachable via actor messaging and respond to
107//! [`IntrospectMessage`]. Infrastructure procs that are
108//! **non-routable** are intentionally **opaque** to introspection and
109//! are omitted from the navigation graph.
110//!
111//! ## Definitions
112//!
113//! **Routable** — an entity is routable if the system can address it
114//! via the routing layer and successfully deliver a message to it
115//! using a `Addr` / `ActorAddr` (i.e., there exists a live mailbox
116//! sender reachable through normal routing). Practical test: "can I
117//! send `IntrospectMessage::Query` to it and get a reply?"
118//!
119//! **Non-routable** — an entity is non-routable if it has no
120//! externally reachable mailbox sender in the routing layer, so
121//! message delivery is impossible by construction (even if you know
122//! its name). Examples: `hyperactor_runtime[0]`, `mailbox_server[N]`,
123//! `local[N]` — these use `PanickingMailboxSender` and are never
124//! bound to the router.
125//!
126//! **Introspectable** — tooling can obtain a `NodePayload` for this
127//! node by sending `IntrospectMessage` to a routable actor.
128//!
129//! **Opaque** — the node exists but is not introspectable via
130//! messaging; tooling cannot observe it through the introspection
131//! protocol.
132//!
133//! ## Proc visibility
134//!
135//! A proc is not directly introspected; actors are. Tooling
136//! synthesizes proc-level nodes by grouping introspectable actors by
137//! `ProcAddr`.
138//!
139//! A proc is visible iff there exists at least one actor on that proc
140//! whose `ActorAddr` is deliverable via the routing layer (i.e., the
141//! actor has a bound mailbox sender reachable through normal routing)
142//! and responds to `IntrospectMessage`.
143//!
144//! The rule is: **if an entity is routable via the mesh routing layer
145//! (i.e., tooling can deliver `IntrospectMessage::Query` to one of its
146//! actors), then it is introspectable and appears in the admin graph.**
147//!
148//! ## Navigation identity invariants (NI-*)
149//!
150//! Every `NodePayload` in the topology tree satisfies:
151//!
152//! - **NI-1 (identity = reference):** A node's `identity: NodeRef`
153//!   must correspond to the reference used to resolve it. The
154//!   display form of `identity` round-trips through `NodeRef::from_str`.
155//!
156//! - **NI-2 (parent = containment parent):** A node's
157//!   `parent: Option<NodeRef>` records its canonical containment
158//!   parent, not the inverse of every navigation edge. Specifically:
159//!   root → `None`, host → `Root`, proc → `Host(…)`,
160//!   actor → `Proc(…)`. An actor's parent is always its owning proc,
161//!   even when the actor also appears as a child of another actor via
162//!   supervision.
163//!
164//! - **NI-3 (children = navigation graph):** A node's `children`
165//!   is the admin navigation graph. Actor-to-actor supervision links
166//!   coexist with proc→actor membership links without changing
167//!   `parent`. The same actor may therefore appear in `children` of
168//!   both its proc and its supervising actor.
169//!
170//! Together these ensure that the TUI can correlate responses to tree
171//! nodes, and that upward/downward navigation is consistent.
172//!
173//! ## Link-classification invariants (LC-*)
174//!
175//! These describe which nodes emit `system_children` and
176//! `stopped_children` classification sets, and what those sets
177//! contain.
178//!
179//! - **LC-1 (root system_children empty):** Root payloads always
180//!   emit `system_children: vec![]`. Root children are host nodes,
181//!   which are not classified as system.
182//!
183//! - **LC-2 (host system_children empty):** Host payloads always
184//!   emit `system_children: vec![]`. Host children are procs, which
185//!   are not classified as system — only actors carry the system
186//!   classification.
187//!
188//! - **LC-3 (proc system_children subset):** Proc payloads emit
189//!   `system_children ⊆ children`, containing only `NodeRef::Actor`
190//!   refs where `cell.is_system()` is true.
191//!
192//! - **LC-4 (proc stopped_children subset):** Proc payloads emit
193//!   `stopped_children ⊆ children`, containing only
194//!   `NodeRef::Actor` refs for terminated actors retained for
195//!   post-mortem inspection.
196//!
197//! - **LC-5 (actor/error no classification sets):** Actor and Error
198//!   payloads do not carry `system_children` or `stopped_children`.
199//!
200//! ## Proc-resolution invariants (SP-*)
201//!
202//! When a proc reference is resolved, the returned `NodePayload`
203//! satisfies:
204//!
205//! - **SP-1 (identity):** The identity matches the ProcAddr reference
206//!   from the parent's children list.
207//! - **SP-2 (properties):** The properties are `NodeProperties::Proc`.
208//! - **SP-3 (parent):** The parent is `NodeRef::Host(actor_id)`.
209//! - **SP-4 (as_of):** The `as_of` field is present and valid
210//!   (internally `SystemTime`; serialized as ISO 8601 string over
211//!   the HTTP JSON API per HB-1).
212//!
213//! Enforced by `test_system_proc_identity`.
214//!
215//! ## Proc-agent invariants (PA-*)
216//!
217//! - **PA-1 (live children):** Proc-node children used by admin/TUI
218//!   must be derived from live proc state at query time. No
219//!   additional publish event is required for a newly spawned actor
220//!   to appear.
221//!
222//! Enforced by `test_proc_children_reflect_directly_spawned_actors`.
223//!
224//! ## Robustness invariant (MA-R1)
225//!
226//! - **MA-R1 (no-crash):** `MeshAdminAgent` must never crash the OS
227//!   process it resides in. Every handler catches errors and converts
228//!   them into structured error payloads
229//!   (`ResolveReferenceResponse(Err(..))`, `NodeProperties::Error`,
230//!   etc.) rather than propagating panics or unwinding. Failed reply
231//!   sends (the caller went away) are silently swallowed.
232//!
233//! ## TLS transport invariants (MA-T*)
234//!
235//! - **MA-T1 (tls):** At Meta (`fbcode_build`), the admin HTTP
236//!   server **requires** mutual TLS. At startup it resolves a paired
237//!   acceptor and credential bundle via
238//!   `try_tls_acceptor_with_pem_bundle` with client cert enforcement
239//!   enabled. If no usable certificate bundle is found, `init()`
240//!   returns an error — no plain HTTP fallback. In OSS, TLS is
241//!   best-effort with plain HTTP fallback.
242//!
243//! - **MA-T2 (scheme-in-url):** The URL returned by `GetAdminAddr`
244//!   is always `https://host:port` or `http://host:port`, never a
245//!   bare `host:port`. All callers receive and use this full URL
246//!   directly.
247//!
248//! - **MA-T3 (access-instructions):** `MeshAdminAgent::init` emits one
249//!   startup access block. For HTTPS, runnable `curl` flags come from
250//!   the exact `PemBundle` paired with the selected acceptor and are
251//!   rendered only when the CA, certificate, and key are `Pem::File`
252//!   or `Pem::StaticPath`. If any credential is `Pem::Value`, the
253//!   block shows endpoint URLs without claiming a runnable `curl`
254//!   command. Plain HTTP endpoints retain runnable `curl` commands.
255//!
256//! ## Client host invariants (CH-*)
257//!
258//! Let **A** denote the aggregated host set (the union of hosts
259//! from all meshes passed to [`host_mesh::spawn_admin`],
260//! deduplicated by `HostAgent` `ActorAddr` — see SA-3), and let
261//! **C** denote the process-global singleton client host mesh in
262//! the caller process (whose local proc hosts the root client
263//! actor).
264//!
265//! - **CH-1 (deduplication):** When C ∈ A, the client host appears
266//!   exactly once in the admin host list (deduplicated by `HostAgent`
267//!   `ActorAddr` identity). When C ∉ A, `spawn_admin` includes C
268//!   alongside A's hosts so the admin introspects C as a normal host
269//!   subtree, not as a standalone proc.
270//!
271//! - **CH-2 (reachability):** In both cases, the root client actor
272//!   is reachable through the standard host → proc → actor walk.
273//!
274//! - **CH-3 (ordering):** C must be initialized before
275//!   `spawn_admin` executes. In Rust, calling `context()` /
276//!   `this_host()` / `this_proc()` triggers `GLOBAL_CONTEXT`
277//!   bootstrap, which initializes C. In Python, `bootstrap_host()`
278//!   calls `register_client_host()` before any actor code runs.
279//!   Either path ensures C is available by the time `spawn_admin`
280//!   reads it via `try_this_host()`. Any refactor must preserve
281//!   this ordering.
282//!
283//! - **CH-4 (runtime-agnostic client-host discovery):** `spawn_admin`
284//!   discovers C via `try_this_host()`, which checks two sources
285//!   in order: the Rust `GLOBAL_CONTEXT` (initialized via
286//!   `context()` / `this_host()` / `this_proc()`) and the
287//!   externally registered client host (set by
288//!   `register_client_host()` from Python's `bootstrap_host()`).
289//!   Aggregation logic must not branch on which source provided C.
290//!
291//! **Mechanism:** [`host_mesh::spawn_admin`] aggregates hosts from
292//! all input meshes (SA-3), reads C from the caller process (via
293//! `try_this_host()`), merges it with the aggregated set (SA-6),
294//! deduplicates by `HostAgent` `ActorAddr`, and spawns the
295//! `MeshAdminAgent` on the caller's local proc via
296//! `cx.instance().proc().spawn(...)`. Placement now follows the
297//! caller context rather than mesh topology.
298//!
299//! ## Spawn/aggregation invariants (SA-*)
300//!
301//! [`host_mesh::spawn_admin`] aggregates hosts from one or more
302//! meshes into a single admin host set.
303//!
304//! - **SA-1 (non-empty mesh set):** The input must yield at least
305//!   one mesh.
306//! - **SA-2 (non-empty hosts):** Every input mesh must contain at
307//!   least one host.
308//! - **SA-3 (host-agent identity dedup):** The admin host set is
309//!   the ordered union of host agents from all input meshes,
310//!   deduplicated by `HostAgent` `ActorAddr` in first-seen order.
311//! - **SA-4 (single-mesh degeneracy):** `spawn_admin([mesh], ...)`
312//!   is behaviorally equivalent to the former `mesh.spawn_admin(...)`.
313//!   Established by existing single-mesh integration tests (e.g.
314//!   `dining_philosophers`); no dedicated unit test.
315//! - **SA-5 (caller-local placement):** The admin is spawned on the
316//!   caller's local proc — the `Proc` of the actor context passed to
317//!   `spawn_admin()`. In common remote launch flows, the caller is
318//!   typically the root client/control process.
319//! - **SA-6 (client-host merge after aggregation):** Client-host
320//!   inclusion/dedup (CH-1) operates on the already-aggregated host
321//!   set, not per-mesh independently.
322//!
323//! ## Host identity and proc ownership
324//!
325//! The root lists hosts by `HostAgent` identity (`HostAgentKey`): the
326//! agent's `ActorId`, which is unique, except for the legacy
327//! `service` pseudo-singleton, whose `ActorId` every such host
328//! shares, so its full `ActorAddr` tells them apart. A channel
329//! address is not a host identity: a client attached through a
330//! gateway advertises the gateway host's channel address, so two
331//! hosts can share one.
332//!
333//! A host alone at its channel address owns the procs at that
334//! address. Where several hosts share an address, the address only
335//! selects them as candidates, and the host whose introspection child
336//! list includes the exact proc owns it. Resolving a configured host
337//! records the procs it lists, so the ordinary root → host → proc walk
338//! needs no further ownership queries. Ownership is never read from
339//! the routing hops of a proc's location.
340//!
341//! ## MAST resolution (disabled)
342//!
343//! `mast_conda:///` resolution is disabled. The old topology-based
344//! resolution assumed the admin lived on the first mesh head host,
345//! which is no longer true after SA-5 changed to caller-local
346//! placement. All resolution paths now return explicit errors.
347//! A publication-based discovery mechanism will replace this in a
348//! future change. Until then, discover the admin URL from
349//! startup output or another launch-time publication.
350//!
351//! ## Admin self-identification invariants (AI-*)
352//!
353//! - **AI-1 (live identity):** `GET /v1/admin` returns the live
354//!   admin actor identity as `AdminInfo`.
355//! - **AI-2 (reported proc):** `proc_id` reports the hosting proc.
356//!   Placement equality (SA-5) is proved by unit tests; integration
357//!   tests validate that `proc_id` is populated and well-formed.
358//! - **AI-3 (url consistency):** `url` matches `GetAdminAddr`.
359//!
360//! The relationship between `host` and `url` (formerly AI-4) is
361//! now a constructor guarantee of [`AdminInfo::new`] rather than a
362//! live invariant. It is not in this registry.
363
364use std::collections::HashMap;
365use std::collections::HashSet;
366use std::io;
367use std::path::Path;
368use std::sync::Arc;
369use std::time::Duration;
370
371use async_trait::async_trait;
372use axum::Json;
373use axum::Router;
374use axum::extract::Path as AxumPath;
375use axum::extract::State;
376use axum::http::StatusCode;
377use axum::response::IntoResponse;
378use axum::routing::get;
379use axum::routing::post;
380use futures::StreamExt;
381use hyperactor::Actor;
382use hyperactor::ActorHandle;
383use hyperactor::ActorRef;
384use hyperactor::Context;
385use hyperactor::Endpoint as _;
386use hyperactor::HandleClient;
387use hyperactor::Handler;
388use hyperactor::Instance;
389use hyperactor::OncePortRef;
390use hyperactor::ProcAddr;
391use hyperactor::RefClient;
392use hyperactor::channel::ChannelAddr;
393use hyperactor::channel::try_tls_acceptor_with_pem_bundle;
394use hyperactor::config::Pem;
395use hyperactor::config::PemBundle;
396use hyperactor::introspect::IntrospectMessage;
397use hyperactor::introspect::IntrospectResult;
398use hyperactor::introspect::IntrospectView;
399use hyperactor::mailbox::open_once_port;
400use serde::Deserialize;
401use serde::Serialize;
402use serde_json::Value;
403use tokio::net::TcpListener;
404use tokio_rustls::TlsAcceptor;
405use typeuri::Named;
406
407use crate::config_dump::ConfigDump;
408use crate::config_dump::ConfigDumpResult;
409use crate::host_mesh::HostAgentKey;
410use crate::host_mesh::host_agent::HOST_MESH_AGENT_ACTOR_NAME;
411use crate::host_mesh::host_agent::HostAgent;
412use crate::introspect::NodePayload;
413use crate::introspect::NodeProperties;
414use crate::introspect::dto::NodePayloadDto;
415use crate::introspect::to_node_payload;
416use crate::proc_agent::PROC_AGENT_ACTOR_NAME;
417use crate::proc_agent::ProcAgent;
418use crate::pyspy::PySpyDump;
419use crate::pyspy::PySpyOpts;
420use crate::pyspy::PySpyProfile;
421use crate::pyspy::PySpyProfileOpts;
422use crate::pyspy::PySpyProfileResult;
423use crate::pyspy::PySpyResult;
424use crate::pyspy::ValidatedProfileRequest;
425
426/// Builds user-facing commands for accessing a mesh admin server.
427fn mesh_admin_access_instructions(admin_url: &str, tls_bundle: Option<&PemBundle>) -> String {
428    fn path(pem: &Pem) -> Option<&Path> {
429        match pem {
430            Pem::File(path) => Some(path),
431            Pem::StaticPath(path) => Some(Path::new(*path)),
432            Pem::Value(_) => None,
433        }
434    }
435
436    let curl_prefix = if admin_url.starts_with("https://") {
437        tls_bundle.and_then(|bundle| {
438            Some(format!(
439                "curl --cacert {} --cert {} --key {} ",
440                path(&bundle.ca)?.display(),
441                path(&bundle.cert)?.display(),
442                path(&bundle.key)?.display(),
443            ))
444        })
445    } else {
446        Some("curl ".to_string())
447    };
448    let access = |path: &str| match &curl_prefix {
449        Some(prefix) => format!("{prefix}{admin_url}{path}"),
450        None => format!("{admin_url}{path}"),
451    };
452
453    format!(
454        concat!(
455            "Mesh admin server listening on {admin_url}\n",
456            "  - Root node:     {root_access}\n",
457            "  - Mesh tree:     {tree_access}\n",
458            "  - API docs:      {docs_access}\n",
459            "  - TUI:           buck2 run ",
460            "fbcode//monarch/hyperactor_mesh_admin_tui:hyperactor_mesh_admin_tui ",
461            "-- --addr {admin_url}\n",
462            "                   cargo run -p hyperactor_mesh_admin_tui_lib ",
463            "--bin hyperactor_mesh_admin_tui -- --addr {admin_url}",
464        ),
465        admin_url = admin_url,
466        root_access = access("/v1/root"),
467        tree_access = access("/v1/tree"),
468        docs_access = access("/SKILL.md"),
469    )
470}
471
472/// Send an `IntrospectMessage` to an actor and receive the reply.
473/// Encapsulates open_once_port + send + timeout + error handling.
474async fn query_introspect(
475    cx: &hyperactor::Context<'_, MeshAdminAgent>,
476    actor_id: &hyperactor::ActorAddr,
477    view: hyperactor::introspect::IntrospectView,
478    timeout: Duration,
479    err_ctx: &str,
480) -> Result<IntrospectResult, anyhow::Error> {
481    let introspect_port = actor_id.introspect_port();
482    let (reply_handle, reply_rx) = open_once_port::<IntrospectResult>(cx);
483    let mut reply_ref = reply_handle.bind();
484    reply_ref.return_undeliverable(false);
485    introspect_port.post(
486        cx,
487        IntrospectMessage::Query {
488            view,
489            reply: reply_ref,
490        },
491    );
492    tokio::time::timeout(timeout, reply_rx.recv())
493        .await
494        .map_err(|_| anyhow::anyhow!("timed out {}", err_ctx))?
495        .map_err(|e| anyhow::anyhow!("failed to receive {}: {}", err_ctx, e))
496}
497
498/// The exact procs that a host payload lists, or an error if the
499/// payload does not describe a host.
500fn host_snapshot(payload: &NodePayload) -> Result<HashSet<ProcAddr>, anyhow::Error> {
501    anyhow::ensure!(
502        matches!(payload.properties, NodeProperties::Host { .. }),
503        "{} did not answer as a host",
504        payload.identity
505    );
506    Ok(payload
507        .children
508        .iter()
509        .filter_map(|child| match child {
510            crate::introspect::NodeRef::Proc(proc) => Some(proc.clone()),
511            _ => None,
512        })
513        .collect())
514}
515
516/// Send an `IntrospectMessage::QueryChild` to an actor.
517async fn query_child_introspect(
518    cx: &hyperactor::Context<'_, MeshAdminAgent>,
519    actor_id: &hyperactor::ActorAddr,
520    child_ref: hyperactor::Addr,
521    timeout: Duration,
522    err_ctx: &str,
523) -> Result<IntrospectResult, anyhow::Error> {
524    let introspect_port = actor_id.introspect_port();
525    let (reply_handle, reply_rx) = open_once_port::<IntrospectResult>(cx);
526    let mut reply_ref = reply_handle.bind();
527    reply_ref.return_undeliverable(false);
528    introspect_port.post(
529        cx,
530        IntrospectMessage::QueryChild {
531            child_ref,
532            reply: reply_ref,
533        },
534    );
535    tokio::time::timeout(timeout, reply_rx.recv())
536        .await
537        .map_err(|_| anyhow::anyhow!("timed out {}", err_ctx))?
538        .map_err(|e| anyhow::anyhow!("failed to receive {}: {}", err_ctx, e))
539}
540
541/// Actor name used when spawning the mesh admin agent.
542pub const MESH_ADMIN_ACTOR_NAME: &str = "mesh_admin";
543
544/// Actor name for the HTTP bridge client mailbox on the service proc.
545///
546/// Unlike `MESH_ADMIN_ACTOR_NAME`, this is not a full actor: it is a
547/// client-mode `Instance<()>` created via
548/// `Proc::introspectable_instance()` and driven by Axum's Tokio task
549/// pool rather than an actor message loop. A separate instance is
550/// required because `MeshAdminAgent`'s own `Instance<Self>` is only
551/// accessible inside its message loop and cannot be shared with
552/// external tasks. This instance gives the HTTP handlers a routable
553/// proc identity so they can open one-shot reply ports
554/// (`open_once_port`) to receive responses from `MeshAdminAgent`.
555///
556/// Unlike a plain `client()`, this uses
557/// `Proc::introspectable_instance()` so the bridge responds to
558/// `IntrospectMessage::Query` and appears as a navigable node in the
559/// mesh TUI rather than causing a 504 when selected.
560pub const MESH_ADMIN_BRIDGE_NAME: &str = "mesh_admin_bridge";
561
562/// Structured error response following the gateway RFC envelope
563/// pattern.
564#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
565pub struct ApiError {
566    /// Machine-readable error code (e.g. "not_found", "bad_request").
567    pub code: String,
568    /// Human-readable error message.
569    pub message: String,
570    /// Additional context about the error. Schema is permissive
571    /// (any valid JSON) — `details` is a domain-specific escape
572    /// hatch. Do not assume a fixed shape.
573    #[serde(skip_serializing_if = "Option::is_none")]
574    pub details: Option<Value>,
575}
576
577/// Wrapper for the structured error envelope.
578#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
579pub struct ApiErrorEnvelope {
580    pub error: ApiError,
581}
582
583impl ApiError {
584    /// Create a "not_found" error.
585    pub fn not_found(message: impl Into<String>, details: Option<Value>) -> Self {
586        Self {
587            code: "not_found".to_string(),
588            message: message.into(),
589            details,
590        }
591    }
592
593    /// Create a "bad_request" error.
594    pub fn bad_request(message: impl Into<String>, details: Option<Value>) -> Self {
595        Self {
596            code: "bad_request".to_string(),
597            message: message.into(),
598            details,
599        }
600    }
601}
602
603impl IntoResponse for ApiError {
604    fn into_response(self) -> axum::response::Response {
605        let status = match self.code.as_str() {
606            "not_found" => StatusCode::NOT_FOUND,
607            "bad_request" => StatusCode::BAD_REQUEST,
608            "gateway_timeout" => StatusCode::GATEWAY_TIMEOUT,
609            "service_unavailable" => StatusCode::SERVICE_UNAVAILABLE,
610            _ => StatusCode::INTERNAL_SERVER_ERROR,
611        };
612        let envelope = ApiErrorEnvelope { error: self };
613        (status, Json(envelope)).into_response()
614    }
615}
616
617/// Response payload for `MeshAdminMessage::GetAdminAddr`.
618///
619/// `addr` is `None` until the admin HTTP server has successfully
620/// bound a listening socket during `MeshAdminAgent::init`.
621#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
622pub struct MeshAdminAddrResponse {
623    pub addr: Option<String>,
624}
625wirevalue::register_type!(MeshAdminAddrResponse);
626
627/// Messages handled by the `MeshAdminAgent`.
628///
629/// These are mesh-admin control-plane queries (as opposed to topology
630/// resolution). They’re wirevalue-serializable and come with
631/// generated client/ref helpers via `HandleClient`/`RefClient`.
632#[derive(
633    Debug,
634    Clone,
635    PartialEq,
636    Serialize,
637    Deserialize,
638    Handler,
639    HandleClient,
640    RefClient,
641    Named
642)]
643pub enum MeshAdminMessage {
644    /// Return the HTTP admin server address that this agent bound in
645    /// `init`.
646    ///
647    /// The reply contains `None` if the server hasn't started yet.
648    GetAdminAddr {
649        #[reply]
650        reply: OncePortRef<MeshAdminAddrResponse>,
651    },
652}
653wirevalue::register_type!(MeshAdminMessage);
654
655/// Newtype wrapper around `Result<NodePayload, String>` for the
656/// resolve reply port (`OncePortRef` requires `Named`).
657#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Named)]
658pub struct ResolveReferenceResponse(pub Result<NodePayload, String>);
659wirevalue::register_type!(ResolveReferenceResponse);
660
661/// Message for resolving a reference (string from HTTP path) into a
662/// `NodePayload`.
663///
664/// This is the primary “navigation” request used by the admin HTTP
665/// bridge: the caller provides a reference (e.g. `"root"`, a `ProcAddr`
666/// string, or an `ActorAddr` string) and the `MeshAdminAgent` returns a
667/// uniformly shaped `NodePayload` plus child references to continue
668/// walking the topology.
669///
670/// The work happens inside the admin actor's message loop so
671/// resolution can:
672/// - parse and validate the reference format,
673/// - dispatch to the right host/proc/actor via existing admin
674///   queries, and
675/// - return a structured payload without blocking HTTP handlers on
676///   mesh logic.
677#[derive(
678    Debug,
679    Clone,
680    PartialEq,
681    Serialize,
682    Deserialize,
683    Handler,
684    HandleClient,
685    RefClient,
686    Named
687)]
688pub enum ResolveReferenceMessage {
689    /// Resolve `reference_string` to a `NodePayload`.
690    ///
691    /// On success the reply contains `payload=Some(..), error=None`; on failure
692    /// it contains `payload=None, error=Some(..)`.
693    Resolve {
694        /// Addr string from the HTTP path, parsed into a typed
695        /// `NodeRef` at the resolve boundary.
696        reference_string: String,
697        /// Reply port receiving the resolution result.
698        #[reply]
699        reply: OncePortRef<ResolveReferenceResponse>,
700    },
701}
702wirevalue::register_type!(ResolveReferenceMessage);
703
704/// Actor that serves a mesh-level admin HTTP endpoint.
705///
706/// `MeshAdminAgent` is the mesh-wide aggregation point for
707/// introspection: it holds `ActorRef<HostAgent>` handles for each
708/// host, and answers admin queries by forwarding targeted requests to
709/// the appropriate host agent and assembling a uniform `NodePayload`
710/// response for the client.
711///
712/// The agent also exposes an HTTP server (spawned from `init`) and
713/// supports reference-based navigation (`GET /v1/{reference}`) by
714/// resolving HTTP path references into typed `NodePayload` values
715/// plus child references.
716#[hyperactor::export(handlers = [MeshAdminMessage, ResolveReferenceMessage])]
717pub struct MeshAdminAgent {
718    /// The configured hosts in first-seen order. The root lists
719    /// exactly these.
720    hosts: Vec<ActorRef<HostAgent>>,
721
722    /// Configured host identity → `HostAgent` reference.
723    ///
724    /// The host agent itself is an actor that can appear in multiple
725    /// places (e.g., as a host node and as a child actor under a
726    /// system proc). Its identities let reference resolution treat
727    /// that actor as a *Host* node (via `resolve_host_node`) rather
728    /// than a generic *Actor* node, avoiding cycles / dropped nodes
729    /// in clients like the TUI.
730    hosts_by_identity: HashMap<HostAgentKey, ActorRef<HostAgent>>,
731
732    /// Terminal channel address → the configured hosts at that
733    /// address. A channel address is not a host identity: a client
734    /// attached through a gateway (`attach(...)`) advertises the
735    /// gateway host's channel address, so both hosts appear here
736    /// under one key. See [`Self::owning_host`].
737    host_candidates_by_addr: HashMap<ChannelAddr, Vec<HostAgentKey>>,
738
739    /// Proc → owning host, learned from successful host snapshots.
740    /// Every value is a key of `hosts_by_identity`. See
741    /// [`Self::apply_host_snapshots`].
742    proc_owners: HashMap<ProcAddr, HostAgentKey>,
743
744    /// `ActorAddr` of the process-global root client (`client[0]` on
745    /// the singleton Host's `local_proc`), exposed as a first-class
746    /// child of the root node. Routable and introspectable via the
747    /// blanket `Handler<IntrospectMessage>`.
748    root_client_actor_id: Option<hyperactor::ActorAddr>,
749
750    /// This agent's own `ActorAddr`, captured during `init`. Used to
751    /// include the admin proc as a visible node in the introspection
752    /// tree (the principle: "if you can send it a message, you can
753    /// introspect it").
754    self_actor_id: Option<hyperactor::ActorAddr>,
755
756    // -- HTTP server address fields --
757    //
758    // The admin HTTP server has three address representations:
759    //
760    //   1. `admin_addr_override` — caller-supplied bind address
761    //      (constructor param). When `None`, `init` reads
762    //      `MESH_ADMIN_ADDR` from config instead.
763    //
764    //   2. `admin_addr` — the actual `SocketAddr` the OS assigned
765    //      after `TcpListener::bind`. Populated during `init`.
766    //
767    //   3. `admin_host` — human-friendly URL with the machine
768    //      hostname (not the raw IP) so it works with DNS and TLS
769    //      certificate validation. Returned via `GetAdminAddr`.
770    /// Caller-supplied bind address. When `None`, `init` reads
771    /// `MESH_ADMIN_ADDR` from config.
772    admin_addr_override: Option<std::net::SocketAddr>,
773
774    /// Actual bound address after `TcpListener::bind`, populated
775    /// during `init`.
776    admin_addr: Option<std::net::SocketAddr>,
777
778    /// Hostname-based URL (e.g. `"https://myhost.facebook.com:1729"`)
779    /// for the admin HTTP server. Returned via `GetAdminAddr`.
780    admin_host: Option<String>,
781
782    /// Base URL of the Monarch dashboard. Passed at construction.
783    /// Used by proxy routes that forward requests to the dashboard's
784    /// `/api/*` endpoints.
785    telemetry_url: Option<String>,
786
787    /// When the mesh was started (ISO-8601 timestamp).
788    started_at: String,
789
790    /// Username who started the mesh.
791    started_by: String,
792}
793
794impl MeshAdminAgent {
795    /// Construct a `MeshAdminAgent` from a list of `(host_addr,
796    /// host_agent_ref)` pairs and an optional root client `ActorAddr`.
797    ///
798    /// `hosts` must hold each host-agent identity once, as
799    /// `spawn_admin` guarantees (SA-3). Hosts with different
800    /// identities may share a channel address. A host's channel
801    /// address is read from its `ActorRef`; the `host_addr` string is
802    /// not used.
803    ///
804    /// When `root_client_actor_id` is `Some`, the root client appears
805    /// as a first-class child of the root node in the introspection
806    /// tree.
807    ///
808    /// The HTTP listen address is initialized to `None` and populated
809    /// during `init()` after the server socket is bound.
810    pub fn new(
811        hosts: Vec<(String, ActorRef<HostAgent>)>,
812        root_client_actor_id: Option<hyperactor::ActorAddr>,
813        admin_addr: Option<std::net::SocketAddr>,
814        telemetry_url: Option<String>,
815    ) -> Self {
816        let mut hosts_by_identity = HashMap::new();
817        let mut host_candidates_by_addr: HashMap<ChannelAddr, Vec<HostAgentKey>> = HashMap::new();
818        let mut ordered = Vec::with_capacity(hosts.len());
819        for (_, agent_ref) in hosts {
820            let key = HostAgentKey::new(agent_ref.actor_addr());
821            assert!(
822                hosts_by_identity
823                    .insert(key.clone(), agent_ref.clone())
824                    .is_none(),
825                "host agent {} is listed more than once",
826                agent_ref.actor_addr()
827            );
828            host_candidates_by_addr
829                .entry(agent_ref.actor_addr().addr().clone())
830                .or_default()
831                .push(key);
832            ordered.push(agent_ref);
833        }
834
835        // Capture start time and username
836        let started_at = chrono::Utc::now().to_rfc3339();
837        let started_by = std::env::var("USER")
838            .or_else(|_| std::env::var("USERNAME"))
839            .unwrap_or_else(|_| "unknown".to_string());
840
841        Self {
842            hosts: ordered,
843            hosts_by_identity,
844            host_candidates_by_addr,
845            proc_owners: HashMap::new(),
846            root_client_actor_id,
847            self_actor_id: None,
848            admin_addr_override: admin_addr,
849            admin_addr: None,
850            admin_host: None,
851            telemetry_url,
852            started_at,
853            started_by,
854        }
855    }
856}
857
858impl std::fmt::Debug for MeshAdminAgent {
859    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
860        f.debug_struct("MeshAdminAgent")
861            .field("hosts", &self.hosts)
862            .field("proc_owners", &self.proc_owners.len())
863            .field("root_client_actor_id", &self.root_client_actor_id)
864            .field("self_actor_id", &self.self_actor_id)
865            .field("admin_addr", &self.admin_addr)
866            .field("admin_host", &self.admin_host)
867            .field("started_at", &self.started_at)
868            .field("started_by", &self.started_by)
869            .finish()
870    }
871}
872
873/// Self-identification payload returned by `GET /v1/admin`.
874///
875/// Construct via [`AdminInfo::new`]. AI-1, AI-2, AI-3 are live
876/// invariants. The relationship between `host` and `url` is a
877/// constructor guarantee — `AdminInfo::new()` rejects URLs with no
878/// host, so `host` always derives from `url` at construction.
879#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
880pub struct AdminInfo {
881    /// Stringified `ActorAddr` of the `MeshAdminAgent`.
882    pub actor_id: String,
883    /// Stringified `ProcAddr` of the proc hosting `MeshAdminAgent`.
884    pub proc_id: String,
885    /// Hostname the admin HTTP server bound on (derived from `url`).
886    pub host: String,
887    /// Full admin URL (e.g. `"https://myhost.facebook.com:1729"`).
888    pub url: String,
889}
890
891impl AdminInfo {
892    /// Construct from identity components and a full admin URL.
893    ///
894    /// Parses `url` strictly using the `url` crate. Returns an error
895    /// if the URL is invalid or has no host component. `host` is
896    /// derived from the parsed URL — the relationship between `host`
897    /// and `url` holds by construction, not by test.
898    pub fn new(actor_id: String, proc_id: String, url: String) -> anyhow::Result<Self> {
899        let parsed = url::Url::parse(&url)
900            .map_err(|e| anyhow::anyhow!("invalid admin URL '{}': {}", url, e))?;
901        let host = parsed
902            .host_str()
903            .ok_or_else(|| anyhow::anyhow!("admin URL '{}' has no host", url))?
904            .to_string();
905        Ok(Self {
906            actor_id,
907            proc_id,
908            host,
909            url,
910        })
911    }
912}
913
914/// Shared state for the reference-based `/v1/{*reference}` bridge
915/// route.
916///
917/// The HTTP handler itself is intentionally thin and does not perform
918/// any routing logic. Instead, it forwards each request into the
919/// `MeshAdminAgent` actor via `ResolveReferenceMessage`, ensuring
920/// resolution happens inside the actor message loop (with access to
921/// actor messaging, timeouts, and indices).
922struct BridgeState {
923    /// Addr to the `MeshAdminAgent` actor that performs
924    /// reference resolution.
925    admin_ref: ActorRef<MeshAdminAgent>,
926    /// Exact actor identities of the HostAgents managed by this admin.
927    host_agents: HashSet<HostAgentKey>,
928    /// Dedicated client mailbox on system_proc for HTTP bridge reply
929    /// ports. Using a separate `Instance<()>` avoids sharing the
930    /// actor's own mailbox with the HTTP bridge and ensures the
931    /// bridge context is routable via system_proc's frontend address.
932    // Previous approach used `this.clone_for_py()` which cloned the
933    // admin actor's Instance:
934    //   bridge_cx: Instance<MeshAdminAgent>,
935    bridge_cx: Instance<()>,
936    /// Limits the number of in-flight resolve requests to prevent
937    /// introspection queries from overwhelming the shared tokio
938    /// runtime and starving user actor workloads.
939    resolve_semaphore: tokio::sync::Semaphore,
940    /// Keep the handle alive so the bridge mailbox is not dropped.
941    _bridge_handle: ActorHandle<()>,
942    /// Base URL of the Monarch dashboard (e.g.
943    /// `"http://localhost:5000"`). Passed from `MeshAdminAgent` at
944    /// init time. Used by proxy routes that forward requests to the
945    /// dashboard's `/api/*` endpoints.
946    telemetry_url: Option<String>,
947    /// Shared HTTP client for outbound proxy requests to the
948    /// dashboard. Reuses connection pool across requests.
949    http_client: reqwest::Client,
950    /// Self-identification metadata, populated during admin init.
951    admin_info: AdminInfo,
952}
953
954/// Build an HTTP client for outbound proxy requests to the dashboard.
955///
956/// Loads the root CA via the same bundle-probing logic used by every
957/// other mesh-admin client so the reqwest client can verify the
958/// dashboard's TLS cert. Falls back to the default trust store (and
959/// a default client) when no bundle is available.
960fn build_http_client() -> reqwest::Client {
961    use std::io::Read;
962
963    if let Some(bundle) = hyperactor::channel::try_tls_pem_bundle() {
964        let mut ca_bytes = Vec::new();
965        if let Ok(mut reader) = bundle.ca.reader()
966            && reader.read_to_end(&mut ca_bytes).is_ok()
967        {
968            let (builder, ca_installed) = crate::mesh_admin_client::add_tls(
969                reqwest::Client::builder(),
970                &ca_bytes,
971                None,
972                None,
973            );
974            if ca_installed {
975                if let Ok(client) = builder.build() {
976                    return client;
977                }
978                tracing::warn!(
979                    "mesh admin: failed to build reqwest client with root CA; \
980                         falling back to default trust store"
981                );
982            }
983        }
984    }
985    reqwest::Client::new()
986}
987
988/// A TCP listener that performs a TLS handshake on each accepted
989/// connection before handing it to axum.
990///
991/// Implements [`axum::serve::Listener`] so it can be passed directly
992/// to [`axum::serve`].  Per the trait contract, `accept` handles
993/// errors internally (logging + retrying) and never returns `Err`.
994struct TlsListener {
995    tcp: TcpListener,
996    acceptor: TlsAcceptor,
997}
998
999impl axum::serve::Listener for TlsListener {
1000    type Io = tokio_rustls::server::TlsStream<tokio::net::TcpStream>;
1001    type Addr = std::net::SocketAddr;
1002
1003    async fn accept(&mut self) -> (Self::Io, Self::Addr) {
1004        loop {
1005            let (stream, addr) = match self.tcp.accept().await {
1006                Ok(conn) => conn,
1007                Err(e) => {
1008                    tracing::warn!("TCP accept error: {}", e);
1009                    continue;
1010                }
1011            };
1012
1013            match self.acceptor.accept(stream).await {
1014                Ok(tls_stream) => return (tls_stream, addr),
1015                Err(e) => {
1016                    tracing::warn!("TLS handshake failed from {}: {}", addr, e);
1017                    continue;
1018                }
1019            }
1020        }
1021    }
1022
1023    fn local_addr(&self) -> io::Result<Self::Addr> {
1024        self.tcp.local_addr()
1025    }
1026}
1027
1028#[async_trait]
1029impl Actor for MeshAdminAgent {
1030    /// Initializes the mesh admin agent and its HTTP server.
1031    ///
1032    /// 1. Binds well-known handler ports (`proc.spawn_with_label()` does not
1033    ///    call `bind()` — unlike `gspawn` — so the actor must do it
1034    ///    itself before becoming reachable).
1035    /// 2. Binds a TCP listener (ephemeral or fixed port).
1036    /// 3. Builds a TLS acceptor and retains its selected credential
1037    ///    bundle (explicit env vars, then Meta default paths). At Meta
1038    ///    (`fbcode_build`), mTLS is mandatory and init fails if no certs
1039    ///    are found. In OSS, falls back to plain HTTP.
1040    /// 4. Creates a dedicated `Instance<()>` client mailbox on
1041    ///    system_proc for the HTTP bridge's reply ports, keeping
1042    ///    bridge traffic off the actor's own mailbox.
1043    /// 5. Spawns the axum server in a background task (HTTPS with
1044    ///    mTLS at Meta, HTTPS or HTTP in OSS depending on step 3).
1045    /// 6. Prints one access block derived from the selected bundle.
1046    ///
1047    /// The hostname-based listen address is stored in `admin_host` so
1048    /// it can be returned via `GetAdminAddr`. The scheme (`https://`
1049    /// or `http://`) is included so clients know which protocol to
1050    /// use.
1051    async fn init(&mut self, this: &Instance<Self>) -> Result<(), anyhow::Error> {
1052        // Bind well-known ports before the HTTP server is spawned, so
1053        // messages (including Undeliverable bounces) can be delivered
1054        // as soon as the admin is reachable.
1055        this.bind::<Self>();
1056        this.set_system();
1057        self.self_actor_id = Some(this.self_addr().clone());
1058
1059        let bind_addr = match self.admin_addr_override {
1060            Some(addr) => addr,
1061            None => hyperactor_config::global::get_cloned(crate::config::MESH_ADMIN_ADDR)
1062                .parse_socket_addr()
1063                .map_err(|e| anyhow::anyhow!("invalid MESH_ADMIN_ADDR config: {}", e))?,
1064        };
1065        let listener = TcpListener::bind(bind_addr).await?;
1066        let bound_addr = listener.local_addr()?;
1067        self.admin_addr = Some(bound_addr);
1068
1069        // At Meta: mTLS is mandatory — fail if no certs are found.
1070        // In OSS: TLS is best-effort with plain HTTP fallback.
1071        // See MA-T1 in module doc.
1072        let enforce_mtls = cfg!(fbcode_build);
1073        let tls = try_tls_acceptor_with_pem_bundle(enforce_mtls);
1074
1075        if enforce_mtls && tls.is_none() {
1076            return Err(anyhow::anyhow!(
1077                "mesh admin requires mTLS but no TLS certificates found; \
1078                 set HYPERACTOR_TLS_CERT/KEY/CA or ensure Meta cert paths exist \
1079                 (/var/facebook/x509_identities/server.pem, /var/facebook/rootcanal/ca.pem)"
1080            ));
1081        }
1082
1083        let scheme = if tls.is_some() { "https" } else { "http" };
1084
1085        // Build the host portion of the admin URL.
1086        //
1087        // Explicit bind (loopback, specific IP): honour the caller's
1088        // choice — they bound that address intentionally.
1089        //
1090        // Wildcard bind: choose an advertised host that the loaded
1091        // TLS certificate actually authorizes. Extract SANs from the
1092        // cert and pick the first candidate that matches. This avoids
1093        // emitting a URL that fails TLS verification.
1094        let host = if !bound_addr.ip().is_unspecified() {
1095            let ip = bound_addr.ip();
1096            if ip.is_loopback() {
1097                "localhost".to_string()
1098            } else if let std::net::IpAddr::V6(v6) = ip {
1099                format!("[{}]", v6)
1100            } else {
1101                ip.to_string()
1102            }
1103        } else {
1104            advertised_host::from_cert_sans()
1105        };
1106        self.admin_host = Some(format!("{}://{}:{}", scheme, host, bound_addr.port()));
1107
1108        // Create a dedicated client mailbox on system_proc for the
1109        // HTTP bridge's reply ports. This avoids sharing the admin
1110        // actor's own mailbox with async HTTP handlers.
1111        let (bridge_cx, bridge_handle) = this
1112            .proc()
1113            .introspectable_instance(MESH_ADMIN_BRIDGE_NAME)?;
1114        bridge_cx.set_system();
1115        let admin_url = self
1116            .admin_host
1117            .clone()
1118            .unwrap_or_else(|| "unknown".to_string());
1119        let access_instructions =
1120            mesh_admin_access_instructions(&admin_url, tls.as_ref().map(|(_, bundle)| bundle));
1121        let bridge_state = Arc::new(BridgeState {
1122            admin_ref: ActorRef::attest(this.self_addr().clone()),
1123            host_agents: self.hosts_by_identity.keys().cloned().collect(),
1124            bridge_cx,
1125            resolve_semaphore: tokio::sync::Semaphore::new(hyperactor_config::global::get(
1126                crate::config::MESH_ADMIN_MAX_CONCURRENT_RESOLVES,
1127            )),
1128            _bridge_handle: bridge_handle,
1129            telemetry_url: self.telemetry_url.clone(),
1130            http_client: build_http_client(),
1131            admin_info: AdminInfo::new(
1132                this.self_addr().to_string(),
1133                this.self_addr().proc_addr().to_string(),
1134                admin_url,
1135            )?,
1136        });
1137        let router = create_mesh_admin_router(bridge_state);
1138
1139        if let Some((acceptor, _)) = tls {
1140            let tls_listener = TlsListener {
1141                tcp: listener,
1142                acceptor,
1143            };
1144            tokio::spawn(async move {
1145                if let Err(e) = axum::serve(tls_listener, router).await {
1146                    tracing::error!("mesh admin server (mTLS) error: {}", e);
1147                }
1148            });
1149        } else {
1150            // OSS fallback: plain HTTP (only reachable when !fbcode_build).
1151            tokio::spawn(async move {
1152                if let Err(e) = axum::serve(listener, router).await {
1153                    tracing::error!("mesh admin server error: {}", e);
1154                }
1155            });
1156        }
1157
1158        println!("{access_instructions}");
1159        tracing::info!(
1160            "mesh admin server listening on {}",
1161            self.admin_host.as_deref().unwrap_or("unknown")
1162        );
1163        Ok(())
1164    }
1165
1166    /// Swallow undeliverable message bounces instead of crashing.
1167    ///
1168    /// The admin agent sends `IntrospectMessage` to actors that may
1169    /// not have the introspection port bound (e.g. actors spawned
1170    /// via `cx.spawn()` whose `#[export]` list does not include it).
1171    /// When the message cannot be delivered, the routing layer
1172    /// bounces an `Undeliverable` back to the sender. The default
1173    /// delivery-failure handling would fail the actor, which
1174    /// would kill this admin agent and — via supervision cascade —
1175    /// take down the entire admin process with `exit(1)`.
1176    ///
1177    /// Since the admin agent is best-effort infrastructure, an
1178    /// undeliverable introspection probe is not a fatal error.
1179    async fn handle_undeliverable_message(
1180        &mut self,
1181        _cx: &Instance<Self>,
1182        _reason: hyperactor::mailbox::UndeliverableReason,
1183        undeliverable: hyperactor::mailbox::Undeliverable<hyperactor::mailbox::MessageEnvelope>,
1184    ) -> Result<(), anyhow::Error> {
1185        match undeliverable {
1186            hyperactor::mailbox::Undeliverable::Returned(envelope) => {
1187                tracing::debug!(
1188                    "admin agent: undeliverable message to {} (port not bound?), ignoring",
1189                    envelope.dest(),
1190                );
1191            }
1192            hyperactor::mailbox::Undeliverable::Report(report) => {
1193                tracing::debug!(
1194                    "admin agent: undeliverable message report to {} ({}), ignoring",
1195                    report.dest,
1196                    report.error_msg().unwrap_or_default(),
1197                );
1198            }
1199        }
1200        Ok(())
1201    }
1202
1203    async fn handle_invalid_reference(
1204        &mut self,
1205        _cx: &Instance<Self>,
1206        invalid: hyperactor::mailbox::InvalidReference,
1207        undeliverable: hyperactor::mailbox::Undeliverable<hyperactor::mailbox::MessageEnvelope>,
1208    ) -> Result<(), anyhow::Error> {
1209        tracing::debug!(
1210            %invalid,
1211            "admin agent: invalid reference from introspection probe, ignoring",
1212        );
1213        match undeliverable {
1214            hyperactor::mailbox::Undeliverable::Returned(envelope) => {
1215                tracing::debug!(
1216                    "admin agent: undeliverable message to {} (invalid reference), ignoring",
1217                    envelope.dest(),
1218                );
1219            }
1220            hyperactor::mailbox::Undeliverable::Report(report) => {
1221                tracing::debug!(
1222                    "admin agent: undeliverable message report to {} ({}), ignoring",
1223                    report.dest,
1224                    report.error_msg().unwrap_or_default(),
1225                );
1226            }
1227        }
1228        Ok(())
1229    }
1230}
1231
1232/// Manual Handler impl — swallows `reply.send()` failures so the
1233/// admin agent stays alive when the HTTP caller disconnects.
1234#[async_trait]
1235impl Handler<MeshAdminMessage> for MeshAdminAgent {
1236    /// Dispatches `MeshAdminMessage` variants.
1237    ///
1238    /// Reply-send failures are swallowed because a dropped receiver
1239    /// (e.g. the HTTP bridge timed out) is not an error — the caller
1240    /// simply went away. Propagating the failure would crash the admin
1241    /// agent and take down the entire process.
1242    async fn handle(
1243        &mut self,
1244        cx: &Context<Self>,
1245        msg: MeshAdminMessage,
1246    ) -> Result<(), anyhow::Error> {
1247        match msg {
1248            MeshAdminMessage::GetAdminAddr { reply } => {
1249                let resp = MeshAdminAddrResponse {
1250                    addr: self.admin_host.clone(),
1251                };
1252                reply.post(cx, resp);
1253            }
1254        }
1255        Ok(())
1256    }
1257}
1258
1259/// Manual Handler impl — swallows `reply.send()` failures so the
1260/// admin agent stays alive when the HTTP caller disconnects.
1261#[async_trait]
1262impl Handler<ResolveReferenceMessage> for MeshAdminAgent {
1263    /// Dispatches `ResolveReferenceMessage` variants.
1264    ///
1265    /// The inner `resolve_reference` call never returns `Err` to the
1266    /// handler — failures are captured in the response payload.
1267    /// Reply-send failures are swallowed for the same reason as
1268    /// `MeshAdminMessage`: a dropped receiver means the caller (HTTP
1269    /// bridge) went away, which must not crash the admin agent.
1270    async fn handle(
1271        &mut self,
1272        cx: &Context<Self>,
1273        msg: ResolveReferenceMessage,
1274    ) -> Result<(), anyhow::Error> {
1275        match msg {
1276            ResolveReferenceMessage::Resolve {
1277                reference_string,
1278                reply,
1279            } => {
1280                let response = ResolveReferenceResponse(
1281                    self.resolve_reference(cx, &reference_string)
1282                        .await
1283                        .map_err(|e| format!("{:#}", e)),
1284                );
1285                reply.post(cx, response);
1286            }
1287        }
1288        Ok(())
1289    }
1290}
1291
1292impl MeshAdminAgent {
1293    /// Core resolver for the reference-based admin API.
1294    ///
1295    /// Parses the caller-provided `reference_string` (or handles the
1296    /// special `"root"` case), then dispatches to
1297    /// `resolve_host_node`, `resolve_proc_node`, or
1298    /// `resolve_actor_node` to assemble a fully-populated
1299    /// `NodePayload` (properties + child references).
1300    ///
1301    /// The returned payload satisfies the **navigation identity
1302    /// invariant** (see module docs): `payload.identity ==
1303    /// reference_string`, and `payload.parent` equals the identity of
1304    /// the node this one appears under.
1305    ///
1306    /// Note: this returns `Err` for internal use; the public
1307    /// `resolve` handler converts failures into
1308    /// `ResolveReferenceResponse(Err(..))` so the actor never crashes
1309    /// on
1310    /// lookup errors.
1311    async fn resolve_reference(
1312        &mut self,
1313        cx: &Context<'_, Self>,
1314        reference_string: &str,
1315    ) -> Result<NodePayload, anyhow::Error> {
1316        let node_ref: crate::introspect::NodeRef = reference_string
1317            .parse()
1318            .map_err(|e| anyhow::anyhow!("invalid reference '{}': {}", reference_string, e))?;
1319
1320        match &node_ref {
1321            crate::introspect::NodeRef::Root => Ok(self.build_root_payload()),
1322            crate::introspect::NodeRef::Host(actor_id) => {
1323                self.resolve_host_node(cx, actor_id).await
1324            }
1325            crate::introspect::NodeRef::Proc(proc_id) => {
1326                match self.resolve_proc_node(cx, proc_id).await {
1327                    Ok(payload) => Ok(payload),
1328                    Err(_) if self.standalone_proc_anchor(proc_id).is_some() => {
1329                        self.resolve_standalone_proc_node(cx, proc_id).await
1330                    }
1331                    Err(e) => Err(e),
1332                }
1333            }
1334            crate::introspect::NodeRef::Actor(actor_id) => {
1335                self.resolve_actor_node(cx, actor_id).await
1336            }
1337        }
1338    }
1339
1340    /// Returns the known actors on standalone procs — procs not
1341    /// managed by any host but whose actors are routable and
1342    /// introspectable. Each proc appears as a root child; the
1343    /// actor is the "anchor" used to discover the proc's contents.
1344    ///
1345    /// The root client is no longer standalone: spawn_admin registers
1346    /// C (the bootstrap host) as a normal host entry (A/C invariant).
1347    fn standalone_proc_actors(&self) -> impl Iterator<Item = &hyperactor::ActorAddr> {
1348        std::iter::empty()
1349    }
1350
1351    /// If `proc_id` belongs to a standalone proc, return the anchor
1352    /// actor on that proc. Returns `None` for host-managed procs.
1353    fn standalone_proc_anchor(&self, proc_id: &ProcAddr) -> Option<&hyperactor::ActorAddr> {
1354        self.standalone_proc_actors()
1355            .find(|actor_id| actor_id.proc_addr() == *proc_id)
1356    }
1357
1358    /// Returns true if `actor_id` lives on a standalone proc.
1359    fn is_standalone_proc_actor(&self, actor_id: &hyperactor::ActorAddr) -> bool {
1360        self.standalone_proc_actors()
1361            .any(|a| a.proc_addr() == actor_id.proc_addr())
1362    }
1363
1364    /// Construct the synthetic root node for the reference tree.
1365    ///
1366    /// The root is not a real actor/proc; it's a convenience node
1367    /// that anchors navigation. Its children are `NodeRef::Host`
1368    /// entries for each configured `HostAgent`.
1369    fn build_root_payload(&self) -> NodePayload {
1370        use crate::introspect::NodeRef;
1371
1372        let children: Vec<NodeRef> = self
1373            .hosts
1374            .iter()
1375            .map(|agent| NodeRef::Host(agent.actor_addr().clone()))
1376            .collect();
1377        let system_children: Vec<NodeRef> = Vec::new(); // LC-1
1378        let mut attrs = hyperactor_config::Attrs::new();
1379        attrs.set(crate::introspect::NODE_TYPE, "root".to_string());
1380        attrs.set(crate::introspect::NUM_HOSTS, self.hosts.len());
1381        if let Ok(t) = humantime::parse_rfc3339(&self.started_at) {
1382            attrs.set(crate::introspect::STARTED_AT, t);
1383        }
1384        attrs.set(crate::introspect::STARTED_BY, self.started_by.clone());
1385        attrs.set(crate::introspect::SYSTEM_CHILDREN, system_children.clone());
1386        let attrs_json = serde_json::to_string(&attrs).unwrap_or_else(|_| "{}".to_string());
1387        NodePayload {
1388            identity: NodeRef::Root,
1389            properties: crate::introspect::derive_properties(&attrs_json),
1390            children,
1391            parent: None,
1392            as_of: std::time::SystemTime::now(),
1393        }
1394    }
1395
1396    /// Find the `HostAgent` that owns `proc_id`.
1397    ///
1398    /// An owner already learned from a host snapshot is read from
1399    /// `proc_owners`. Otherwise the proc's terminal channel address
1400    /// selects the configured candidates: with none there is no host,
1401    /// and a sole candidate owns every proc at its address. When
1402    /// several hosts share the address, the address cannot choose, so
1403    /// every candidate's snapshot is fetched and the host that lists
1404    /// the exact proc owns it.
1405    ///
1406    /// Returns `Ok(None)` if every candidate answers and none lists
1407    /// the proc. Returns an error if two hosts claim it, or if a
1408    /// candidate fails and no answering host lists it.
1409    async fn owning_host(
1410        &mut self,
1411        cx: &Context<'_, Self>,
1412        proc_id: &ProcAddr,
1413    ) -> Result<Option<ActorRef<HostAgent>>, anyhow::Error> {
1414        if let Some(owner) = self.proc_owners.get(proc_id) {
1415            return Ok(Some(self.configured_host(owner).clone()));
1416        }
1417        let Some(candidates) = self.host_candidates_by_addr.get(proc_id.addr()) else {
1418            return Ok(None);
1419        };
1420        let candidates: Vec<(HostAgentKey, ActorRef<HostAgent>)> = candidates
1421            .iter()
1422            .map(|key| (key.clone(), self.configured_host(key).clone()))
1423            .collect();
1424        if let [(_, only)] = candidates.as_slice() {
1425            return Ok(Some(only.clone()));
1426        }
1427
1428        // Half the request budget, so that a slow candidate leaves time
1429        // for the proc query that follows.
1430        let timeout =
1431            hyperactor_config::global::get(crate::config::MESH_ADMIN_SINGLE_HOST_TIMEOUT) / 2;
1432        let queries: Vec<_> = candidates
1433            .iter()
1434            .map(|(_, agent)| self.query_host_node(cx, agent.actor_addr(), timeout))
1435            .collect();
1436        let answers: Vec<_> = futures::stream::iter(queries)
1437            .buffered(candidates.len())
1438            .collect()
1439            .await;
1440
1441        let mut snapshots = Vec::new();
1442        let mut failure = None;
1443        for ((key, _), answer) in candidates.into_iter().zip(answers) {
1444            match answer.and_then(|payload| host_snapshot(&payload)) {
1445                Ok(procs) => snapshots.push((key, procs)),
1446                Err(e) => {
1447                    failure.get_or_insert(e);
1448                }
1449            }
1450        }
1451        self.apply_host_snapshots(snapshots)?;
1452
1453        if let Some(owner) = self.proc_owners.get(proc_id) {
1454            return Ok(Some(self.configured_host(owner).clone()));
1455        }
1456        match failure {
1457            Some(e) => Err(e.context(format!(
1458                "cannot tell which host at {} owns {}",
1459                proc_id.addr(),
1460                proc_id
1461            ))),
1462            None => Ok(None),
1463        }
1464    }
1465
1466    /// The configured host with identity `key`.
1467    fn configured_host(&self, key: &HostAgentKey) -> &ActorRef<HostAgent> {
1468        self.hosts_by_identity
1469            .get(key)
1470            .expect("ownership refers only to configured hosts")
1471    }
1472
1473    /// Record the procs listed by successful host snapshots, all or
1474    /// nothing. Each snapshot is a configured host's identity and the
1475    /// exact procs it lists.
1476    ///
1477    /// If any proc would have two owners, because two snapshots list it
1478    /// or because a snapshot lists a proc another host owns, nothing
1479    /// changes and the error names both hosts. Otherwise each host's
1480    /// entries are replaced by its snapshot, which drops procs the host
1481    /// no longer lists.
1482    fn apply_host_snapshots(
1483        &mut self,
1484        snapshots: Vec<(HostAgentKey, HashSet<ProcAddr>)>,
1485    ) -> Result<(), anyhow::Error> {
1486        let mut claims: HashMap<&ProcAddr, &HostAgentKey> = HashMap::new();
1487        for (host, procs) in &snapshots {
1488            anyhow::ensure!(
1489                self.hosts_by_identity.contains_key(host),
1490                "host agent {:?} is not configured",
1491                host
1492            );
1493            for proc in procs {
1494                let other = claims
1495                    .insert(proc, host)
1496                    .filter(|other| *other != host)
1497                    .or_else(|| self.proc_owners.get(proc).filter(|owner| *owner != host));
1498                if let Some(other) = other {
1499                    anyhow::bail!(
1500                        "proc {} is claimed by host agents {} and {}",
1501                        proc,
1502                        self.configured_host(other).actor_addr(),
1503                        self.configured_host(host).actor_addr()
1504                    );
1505                }
1506            }
1507        }
1508        for (host, procs) in snapshots {
1509            self.proc_owners
1510                .retain(|proc, owner| *owner != host || procs.contains(proc));
1511            self.proc_owners
1512                .extend(procs.into_iter().map(|proc| (proc, host.clone())));
1513        }
1514        Ok(())
1515    }
1516
1517    /// Resolve a `HostAgent` actor reference into a host-level
1518    /// `NodePayload`.
1519    ///
1520    /// When the host is configured, the procs it lists are recorded in
1521    /// `proc_owners`, so that a walk from the root through a host to
1522    /// its procs needs no further ownership queries.
1523    async fn resolve_host_node(
1524        &mut self,
1525        cx: &Context<'_, Self>,
1526        actor_id: &hyperactor::ActorAddr,
1527    ) -> Result<NodePayload, anyhow::Error> {
1528        let payload = self
1529            .query_host_node(
1530                cx,
1531                actor_id,
1532                hyperactor_config::global::get(crate::config::MESH_ADMIN_SINGLE_HOST_TIMEOUT),
1533            )
1534            .await?;
1535        let key = HostAgentKey::new(actor_id);
1536        if self.hosts_by_identity.contains_key(&key)
1537            && let Ok(procs) = host_snapshot(&payload)
1538        {
1539            self.apply_host_snapshots(vec![(key, procs)])?;
1540        }
1541        Ok(payload)
1542    }
1543
1544    /// Query a `HostAgent` for its host-level `NodePayload`.
1545    ///
1546    /// Sends `IntrospectMessage::Query` directly to the
1547    /// `HostAgent`, which returns a `NodePayload` with
1548    /// `NodeProperties::Host` and the host's children. The resolver
1549    /// overrides `parent` to `"root"` since the host agent
1550    /// doesn't know its position in the navigation tree.
1551    async fn query_host_node(
1552        &self,
1553        cx: &Context<'_, Self>,
1554        actor_id: &hyperactor::ActorAddr,
1555        timeout: Duration,
1556    ) -> Result<NodePayload, anyhow::Error> {
1557        let err_ctx = format!("querying host agent {}", actor_id);
1558        let result = query_introspect(
1559            cx,
1560            actor_id,
1561            hyperactor::introspect::IntrospectView::Entity,
1562            timeout,
1563            &err_ctx,
1564        )
1565        .await?;
1566        Ok(crate::introspect::to_node_payload_with(
1567            result,
1568            crate::introspect::NodeRef::Host(actor_id.clone()),
1569            Some(crate::introspect::NodeRef::Root),
1570        ))
1571    }
1572
1573    /// Resolve a `ProcAddr` reference into a proc-level `NodePayload`.
1574    ///
1575    /// First tries `IntrospectMessage::QueryChild` against the owning
1576    /// `HostAgent` (which recognizes service and local procs). If
1577    /// that returns an error payload, falls back to `ProcAgent` for
1578    /// user procs by querying
1579    /// `QueryChild(hyperactor::Addr::Proc(proc_id))`
1580    /// on `<proc_id>/proc_agent[0]`.
1581    ///
1582    /// See PA-1 in module doc.
1583    async fn resolve_proc_node(
1584        &mut self,
1585        cx: &Context<'_, Self>,
1586        proc_id: &ProcAddr,
1587    ) -> Result<NodePayload, anyhow::Error> {
1588        let agent = self
1589            .owning_host(cx, proc_id)
1590            .await?
1591            .ok_or_else(|| anyhow::anyhow!("no host found for proc {}", proc_id))?;
1592
1593        // Try the host agent's QueryChild first.
1594        let result = query_child_introspect(
1595            cx,
1596            agent.actor_addr(),
1597            hyperactor::Addr::Proc(proc_id.clone()),
1598            hyperactor_config::global::get(crate::config::MESH_ADMIN_QUERY_CHILD_TIMEOUT),
1599            "querying proc details",
1600        )
1601        .await?;
1602
1603        // If the host recognized the proc, normalize identity and parent.
1604        // The host's QueryChild returns IntrospectRef::Actor(self_id) as
1605        // parent, which lifts to NodeRef::Actor. We need NodeRef::Host.
1606        let payload = crate::introspect::to_node_payload_with(
1607            result,
1608            crate::introspect::NodeRef::Proc(proc_id.clone()),
1609            Some(crate::introspect::NodeRef::Host(agent.actor_addr().clone())),
1610        );
1611        if !matches!(payload.properties, NodeProperties::Error { .. }) {
1612            return Ok(payload);
1613        }
1614
1615        // Fall back to querying the ProcAgent directly (user procs).
1616        let mesh_agent_id = proc_id.actor_addr(PROC_AGENT_ACTOR_NAME);
1617        let result = query_child_introspect(
1618            cx,
1619            &mesh_agent_id,
1620            hyperactor::Addr::Proc(proc_id.clone()),
1621            hyperactor_config::global::get(crate::config::MESH_ADMIN_RESOLVE_ACTOR_TIMEOUT),
1622            "querying proc mesh agent",
1623        )
1624        .await?;
1625
1626        Ok(crate::introspect::to_node_payload_with(
1627            result,
1628            crate::introspect::NodeRef::Proc(proc_id.clone()),
1629            Some(crate::introspect::NodeRef::Host(agent.actor_addr().clone())),
1630        ))
1631    }
1632
1633    /// Resolve a standalone proc into a proc-level `NodePayload`.
1634    ///
1635    /// Standalone procs (e.g. the admin proc) are not managed by any
1636    /// `HostAgent`, so
1637    /// `resolve_proc_node` cannot resolve them. Instead, we query the
1638    /// anchor actor on the proc for its introspection data, collect
1639    /// its supervision children, and build a synthetic proc node.
1640    ///
1641    /// Special case: when the anchor actor is this agent itself, we
1642    /// build the children list directly (just `[self]`) to avoid a
1643    /// self-deadlock — the actor loop cannot process an
1644    /// `IntrospectMessage` it sends to itself while handling a
1645    /// resolve request.
1646    async fn resolve_standalone_proc_node(
1647        &self,
1648        cx: &Context<'_, Self>,
1649        proc_id: &ProcAddr,
1650    ) -> Result<NodePayload, anyhow::Error> {
1651        let actor_id = self
1652            .standalone_proc_anchor(proc_id)
1653            .ok_or_else(|| anyhow::anyhow!("no anchor actor for standalone proc {}", proc_id))?;
1654
1655        use crate::introspect::NodeRef;
1656
1657        let (children, system_children) = if self.self_actor_id.as_ref() == Some(actor_id) {
1658            let self_ref = NodeRef::Actor(actor_id.clone());
1659            (vec![self_ref.clone()], vec![self_ref])
1660        } else {
1661            let actor_result = query_introspect(
1662                cx,
1663                actor_id,
1664                hyperactor::introspect::IntrospectView::Actor,
1665                hyperactor_config::global::get(crate::config::MESH_ADMIN_SINGLE_HOST_TIMEOUT),
1666                &format!("querying anchor actor on {}", proc_id),
1667            )
1668            .await?;
1669            let actor_payload = to_node_payload(actor_result);
1670            let anchor_ref = NodeRef::Actor(actor_id.clone());
1671            let anchor_is_system = matches!(
1672                &actor_payload.properties,
1673                NodeProperties::Actor {
1674                    is_system: true,
1675                    ..
1676                }
1677            );
1678
1679            let mut children = vec![anchor_ref.clone()];
1680            let mut system_children = Vec::new();
1681            if anchor_is_system {
1682                system_children.push(anchor_ref);
1683            }
1684
1685            for child_ref in actor_payload.children {
1686                let child_actor_id = match &child_ref {
1687                    NodeRef::Actor(id) => Some(id),
1688                    _ => None,
1689                };
1690                if let Some(child_actor_id) = child_actor_id {
1691                    let child_is_system = if let Ok(r) = query_introspect(
1692                        cx,
1693                        child_actor_id,
1694                        hyperactor::introspect::IntrospectView::Actor,
1695                        hyperactor_config::global::get(
1696                            crate::config::MESH_ADMIN_RESOLVE_ACTOR_TIMEOUT,
1697                        ),
1698                        "querying child actor is_system",
1699                    )
1700                    .await
1701                    {
1702                        let p = to_node_payload(r);
1703                        matches!(
1704                            &p.properties,
1705                            NodeProperties::Actor {
1706                                is_system: true,
1707                                ..
1708                            }
1709                        )
1710                    } else {
1711                        false
1712                    };
1713                    if child_is_system {
1714                        system_children.push(child_ref.clone());
1715                    }
1716                }
1717                children.push(child_ref);
1718            }
1719            (children, system_children)
1720        };
1721
1722        let proc_name = proc_id
1723            .label()
1724            .map(|l| l.as_str().to_string())
1725            .unwrap_or_else(|| proc_id.id().to_string());
1726
1727        let mut attrs = hyperactor_config::Attrs::new();
1728        attrs.set(crate::introspect::NODE_TYPE, "proc".to_string());
1729        attrs.set(crate::introspect::PROC_NAME, proc_name.clone());
1730        attrs.set(crate::introspect::NUM_ACTORS, children.len());
1731        attrs.set(crate::introspect::SYSTEM_CHILDREN, system_children.clone());
1732        let attrs_json = serde_json::to_string(&attrs).unwrap_or_else(|_| "{}".to_string());
1733
1734        Ok(NodePayload {
1735            identity: NodeRef::Proc(proc_id.clone()),
1736            properties: crate::introspect::derive_properties(&attrs_json),
1737            children,
1738            as_of: std::time::SystemTime::now(),
1739            parent: Some(NodeRef::Root),
1740        })
1741    }
1742
1743    /// Resolve a non-host-agent `ActorAddr` reference into an
1744    /// actor-level `NodePayload`.
1745    ///
1746    /// Sends `IntrospectMessage::Query` directly to the target actor
1747    /// via `PortRef::attest_handler_port`. The blanket handler
1748    /// returns a `NodePayload` with `NodeProperties::Actor` (or a
1749    /// domain-specific override like `NodeProperties::Proc` for
1750    /// `ProcAgent`).
1751    ///
1752    /// The resolver sets `parent` based on the actor's position
1753    /// in the topology: if the actor lives in a system proc, the
1754    /// parent is the system proc ref; otherwise it's the proc's
1755    /// `ProcAddr` string.
1756    async fn resolve_actor_node(
1757        &mut self,
1758        cx: &Context<'_, Self>,
1759        actor_id: &hyperactor::ActorAddr,
1760    ) -> Result<NodePayload, anyhow::Error> {
1761        // Self-resolution: we cannot send IntrospectMessage to our
1762        // own actor loop while handling a resolve request (deadlock).
1763        // Use introspect_payload() to snapshot our own state
1764        // directly.
1765        let result = if self.self_actor_id.as_ref() == Some(actor_id) {
1766            cx.introspect_payload()
1767        } else if self.is_standalone_proc_actor(actor_id) {
1768            // Standalone procs have no ProcAgent — query directly.
1769            query_introspect(
1770                cx,
1771                actor_id,
1772                hyperactor::introspect::IntrospectView::Actor,
1773                hyperactor_config::global::get(crate::config::MESH_ADMIN_SINGLE_HOST_TIMEOUT),
1774                &format!("querying actor {}", actor_id),
1775            )
1776            .await?
1777        } else {
1778            // Check terminated snapshots first — fast, no ambiguity.
1779            let proc_id = actor_id.proc_addr();
1780            let mesh_agent_id = proc_id.actor_addr(PROC_AGENT_ACTOR_NAME);
1781            let terminated = query_child_introspect(
1782                cx,
1783                &mesh_agent_id,
1784                hyperactor::Addr::Actor(actor_id.clone()),
1785                hyperactor_config::global::get(crate::config::MESH_ADMIN_QUERY_CHILD_TIMEOUT),
1786                "querying terminated snapshot",
1787            )
1788            .await
1789            .ok()
1790            .filter(|r| {
1791                let p = crate::introspect::derive_properties(&r.attrs);
1792                !matches!(p, NodeProperties::Error { .. })
1793            });
1794
1795            match terminated {
1796                Some(snapshot) => snapshot,
1797                None => {
1798                    // Not terminated — query the live actor.
1799                    query_introspect(
1800                        cx,
1801                        actor_id,
1802                        hyperactor::introspect::IntrospectView::Actor,
1803                        hyperactor_config::global::get(
1804                            crate::config::MESH_ADMIN_RESOLVE_ACTOR_TIMEOUT,
1805                        ),
1806                        &format!("querying actor {}", actor_id),
1807                    )
1808                    .await?
1809                }
1810            }
1811        };
1812        let mut payload = to_node_payload(result);
1813
1814        if self.is_standalone_proc_actor(actor_id) {
1815            payload.parent = Some(crate::introspect::NodeRef::Proc(actor_id.proc_addr()));
1816            return Ok(payload);
1817        }
1818
1819        let proc_id = actor_id.proc_addr();
1820        match &payload.properties {
1821            NodeProperties::Proc { .. } => {
1822                if let Some(agent) = self.owning_host(cx, &proc_id).await? {
1823                    payload.parent =
1824                        Some(crate::introspect::NodeRef::Host(agent.actor_addr().clone()));
1825                }
1826            }
1827            _ => {
1828                payload.parent = Some(crate::introspect::NodeRef::Proc(proc_id.clone()));
1829            }
1830        }
1831
1832        Ok(payload)
1833    }
1834}
1835
1836/// Build the Axum router for the mesh admin HTTP server.
1837///
1838/// Routes:
1839/// - `GET /v1/schema` — JSON Schema (Draft 2020-12) for `NodePayload`.
1840/// - `GET /v1/schema/error` — JSON Schema for `ApiErrorEnvelope`.
1841/// - `GET /v1/openapi.json` — OpenAPI 3.1 spec (embeds JSON Schemas).
1842/// - `GET /v1/tree` — ASCII topology dump.
1843/// - `POST /v1/query` — proxy SQL query to the dashboard server.
1844/// - `GET /v1/pyspy/{*proc_reference}` — py-spy stack dump for a proc.
1845/// - `POST /v1/pyspy_dump/{*proc_reference}` — py-spy dump + store in Datafusion.
1846/// - `POST /v1/pyspy_profile_svg/{*proc_reference}` — py-spy profile → SVG flamegraph.
1847/// - `GET /v1/config/{*proc_reference}` — config snapshot for a proc.
1848/// - `GET /v1/admin` — admin self-identification (`AdminInfo`).
1849/// - `GET /v1/{*reference}` — JSON `NodePayload` for a single reference.
1850/// - `GET /SKILL.md` — agent-facing API documentation (markdown).
1851fn create_mesh_admin_router(bridge_state: Arc<BridgeState>) -> Router {
1852    Router::new()
1853        .route("/SKILL.md", get(serve_skill_md))
1854        // Literal paths matched by specificity before wildcard (SC-5).
1855        .route("/v1/admin", get(serve_admin_info))
1856        .route("/v1/schema", get(serve_schema))
1857        .route("/v1/schema/admin", get(serve_admin_schema))
1858        .route("/v1/schema/error", get(serve_error_schema))
1859        .route("/v1/openapi.json", get(serve_openapi))
1860        .route("/v1/tree", get(tree_dump))
1861        .route("/v1/query", post(query_proxy))
1862        .route("/v1/pyspy/{*proc_reference}", get(pyspy_bridge))
1863        .route(
1864            "/v1/pyspy_dump/{*proc_reference}",
1865            post(pyspy_dump_and_store),
1866        )
1867        .route(
1868            "/v1/pyspy_profile_svg/{*proc_reference}",
1869            post(pyspy_profile_svg),
1870        )
1871        .route("/v1/config/{*proc_reference}", get(config_bridge))
1872        .route("/v1/{*reference}", get(resolve_reference_bridge))
1873        .with_state(bridge_state)
1874}
1875
1876/// Raw markdown template for the SKILL.md API document.
1877const SKILL_MD_TEMPLATE: &str = include_str!("mesh_admin_skill.md");
1878
1879/// Extract base URL from request headers.
1880///
1881/// Defaults to `https` when `x-forwarded-proto` is absent — the
1882/// admin server uses TLS in production, so `http` is the wrong
1883/// default for direct connections.
1884fn extract_base_url(headers: &axum::http::HeaderMap) -> String {
1885    let host = headers
1886        .get(axum::http::header::HOST)
1887        .and_then(|v| v.to_str().ok())
1888        .unwrap_or("localhost");
1889    let scheme = headers
1890        .get("x-forwarded-proto")
1891        .and_then(|v| v.to_str().ok())
1892        .unwrap_or("https");
1893    format!("{scheme}://{host}")
1894}
1895
1896/// Self-identification endpoint: returns `AdminInfo` (AI-1..AI-3;
1897/// AI-4 is a constructor guarantee of `AdminInfo::new()`).
1898async fn serve_admin_info(
1899    State(state): State<Arc<BridgeState>>,
1900) -> axum::response::Json<AdminInfo> {
1901    axum::response::Json(state.admin_info.clone())
1902}
1903
1904/// JSON Schema for `AdminInfo`.
1905async fn serve_admin_schema() -> Result<axum::response::Json<serde_json::Value>, ApiError> {
1906    Ok(axum::response::Json(schema_with_id::<AdminInfo>(
1907        "https://monarch.meta.com/schemas/v1/admin_info",
1908    )?))
1909}
1910
1911/// Serves the self-describing API document with the base URL
1912/// interpolated so examples are copy-pasteable.
1913async fn serve_skill_md(headers: axum::http::HeaderMap) -> impl axum::response::IntoResponse {
1914    let base = extract_base_url(&headers);
1915    let body = SKILL_MD_TEMPLATE.replace("{base}", &base);
1916    (
1917        [(
1918            axum::http::header::CONTENT_TYPE,
1919            "text/markdown; charset=utf-8",
1920        )],
1921        body,
1922    )
1923}
1924
1925/// Build a JSON Schema value with a `$id` field.
1926fn schema_with_id<T: schemars::JsonSchema>(id: &str) -> Result<serde_json::Value, ApiError> {
1927    let schema = schemars::schema_for!(T);
1928    let mut value = serde_json::to_value(schema).map_err(|e| ApiError {
1929        code: "internal_error".to_string(),
1930        message: format!("failed to serialize schema: {e}"),
1931        details: None,
1932    })?;
1933    if let Some(obj) = value.as_object_mut() {
1934        obj.insert("$id".into(), serde_json::Value::String(id.into()));
1935    }
1936    Ok(value)
1937}
1938
1939/// JSON Schema for the `NodePayload` response type.
1940async fn serve_schema() -> Result<axum::response::Json<serde_json::Value>, ApiError> {
1941    Ok(axum::response::Json(schema_with_id::<NodePayloadDto>(
1942        "https://monarch.meta.com/schemas/v1/node_payload",
1943    )?))
1944}
1945
1946/// JSON Schema for the `ApiErrorEnvelope` error response.
1947async fn serve_error_schema() -> Result<axum::response::Json<serde_json::Value>, ApiError> {
1948    Ok(axum::response::Json(schema_with_id::<ApiErrorEnvelope>(
1949        "https://monarch.meta.com/schemas/v1/error",
1950    )?))
1951}
1952
1953/// Hoist `$defs` from a schemars-generated schema into a shared
1954/// map and rewrite internal `$ref` pointers from `#/$defs/X` to
1955/// `#/components/schemas/X` so OpenAPI tools can resolve them.
1956fn hoist_defs(
1957    schema: &mut serde_json::Value,
1958    shared: &mut serde_json::Map<String, serde_json::Value>,
1959) {
1960    if let Some(obj) = schema.as_object_mut() {
1961        if let Some(defs) = obj.remove("$defs")
1962            && let Some(defs_map) = defs.as_object()
1963        {
1964            for (k, v) in defs_map {
1965                shared.insert(k.clone(), v.clone());
1966            }
1967        }
1968        // Also remove $schema from embedded schemas — it's
1969        // only valid at the root of a JSON Schema document,
1970        // not inside an OpenAPI components/schemas entry.
1971        obj.remove("$schema");
1972    }
1973    rewrite_refs(schema);
1974}
1975
1976/// Recursively rewrite `$ref: "#/$defs/X"` →
1977/// `$ref: "#/components/schemas/X"`.
1978fn rewrite_refs(value: &mut serde_json::Value) {
1979    match value {
1980        serde_json::Value::Object(map) => {
1981            if let Some(serde_json::Value::String(r)) = map.get_mut("$ref")
1982                && r.starts_with("#/$defs/")
1983            {
1984                *r = r.replace("#/$defs/", "#/components/schemas/");
1985            }
1986            for v in map.values_mut() {
1987                rewrite_refs(v);
1988            }
1989        }
1990        serde_json::Value::Array(arr) => {
1991            for v in arr {
1992                rewrite_refs(v);
1993            }
1994        }
1995        _ => {}
1996    }
1997}
1998
1999/// Build the OpenAPI 3.1 spec, embedding schemars-derived JSON
2000/// Schemas into `components/schemas`.
2001pub fn build_openapi_spec() -> serde_json::Value {
2002    let mut node_schema = serde_json::to_value(schemars::schema_for!(NodePayloadDto))
2003        .expect("NodePayload schema must be serializable");
2004    let mut error_schema = serde_json::to_value(schemars::schema_for!(ApiErrorEnvelope))
2005        .expect("ApiErrorEnvelope schema must be serializable");
2006    let mut pyspy_schema = serde_json::to_value(schemars::schema_for!(PySpyResult))
2007        .expect("PySpyResult schema must be serializable");
2008    let mut query_request_schema = serde_json::to_value(schemars::schema_for!(QueryRequest))
2009        .expect("QueryRequest schema must be serializable");
2010    let mut query_response_schema = serde_json::to_value(schemars::schema_for!(QueryResponse))
2011        .expect("QueryResponse schema must be serializable");
2012    let mut pyspy_dump_response_schema =
2013        serde_json::to_value(schemars::schema_for!(PyspyDumpAndStoreResponse))
2014            .expect("PyspyDumpAndStoreResponse schema must be serializable");
2015    let mut admin_info_schema = serde_json::to_value(schemars::schema_for!(AdminInfo))
2016        .expect("AdminInfo schema must be serializable");
2017    let mut profile_opts_schema = serde_json::to_value(schemars::schema_for!(PySpyProfileOpts))
2018        .expect("PySpyProfileOpts schema must be serializable");
2019
2020    // Hoist $defs into a shared components/schemas map so
2021    // OpenAPI tools can resolve references.
2022    let mut shared_schemas = serde_json::Map::new();
2023    hoist_defs(&mut node_schema, &mut shared_schemas);
2024    hoist_defs(&mut error_schema, &mut shared_schemas);
2025    hoist_defs(&mut pyspy_schema, &mut shared_schemas);
2026    hoist_defs(&mut query_request_schema, &mut shared_schemas);
2027    hoist_defs(&mut query_response_schema, &mut shared_schemas);
2028    hoist_defs(&mut pyspy_dump_response_schema, &mut shared_schemas);
2029    hoist_defs(&mut admin_info_schema, &mut shared_schemas);
2030    hoist_defs(&mut profile_opts_schema, &mut shared_schemas);
2031    shared_schemas.insert("NodePayload".into(), node_schema);
2032    shared_schemas.insert("ApiErrorEnvelope".into(), error_schema);
2033    shared_schemas.insert("PySpyResult".into(), pyspy_schema);
2034    shared_schemas.insert("QueryRequest".into(), query_request_schema);
2035    shared_schemas.insert("QueryResponse".into(), query_response_schema);
2036    shared_schemas.insert(
2037        "PyspyDumpAndStoreResponse".into(),
2038        pyspy_dump_response_schema,
2039    );
2040    shared_schemas.insert("AdminInfo".into(), admin_info_schema);
2041    shared_schemas.insert("PySpyProfileOpts".into(), profile_opts_schema);
2042
2043    // Rewrite any remaining $defs refs in the hoisted component schemas.
2044    for value in shared_schemas.values_mut() {
2045        rewrite_refs(value);
2046    }
2047
2048    let error_response = |desc: &str| -> serde_json::Value {
2049        serde_json::json!({
2050            "description": desc,
2051            "content": {
2052                "application/json": {
2053                    "schema": { "$ref": "#/components/schemas/ApiErrorEnvelope" }
2054                }
2055            }
2056        })
2057    };
2058
2059    let success_payload = serde_json::json!({
2060        "description": "Resolved NodePayload",
2061        "content": {
2062            "application/json": {
2063                "schema": { "$ref": "#/components/schemas/NodePayload" }
2064            }
2065        }
2066    });
2067
2068    let mut spec = serde_json::json!({
2069        "openapi": "3.1.0",
2070        "info": {
2071            "title": "Monarch Mesh Admin API",
2072            "version": "1.0.0",
2073            "description": "Address-walking introspection API for a Monarch actor mesh. See the Admin Gateway Pattern RFC."
2074        },
2075        "paths": {
2076            "/v1/root": {
2077                "get": {
2078                    "summary": "Fetch root node",
2079                    "operationId": "getRoot",
2080                    "responses": {
2081                        "200": success_payload,
2082                        "500": error_response("Internal error"),
2083                        "503": error_response("Service unavailable (at capacity, retry with backoff)"),
2084                        "504": error_response("Gateway timeout (downstream host unresponsive)")
2085                    }
2086                }
2087            },
2088            "/v1/{reference}": {
2089                "get": {
2090                    "summary": "Resolve a reference to a NodePayload",
2091                    "operationId": "resolveReference",
2092                    "parameters": [{
2093                        "name": "reference",
2094                        "in": "path",
2095                        "required": true,
2096                        "description": "URL-encoded opaque reference string",
2097                        "schema": { "type": "string" }
2098                    }],
2099                    "responses": {
2100                        "200": success_payload,
2101                        "400": error_response("Bad request (malformed reference)"),
2102                        "404": error_response("Address not found"),
2103                        "500": error_response("Internal error"),
2104                        "503": error_response("Service unavailable (at capacity, retry with backoff)"),
2105                        "504": error_response("Gateway timeout (downstream host unresponsive)")
2106                    }
2107                }
2108            },
2109            "/v1/schema": {
2110                "get": {
2111                    "summary": "JSON Schema for NodePayload (Draft 2020-12)",
2112                    "operationId": "getSchema",
2113                    "responses": {
2114                        "200": {
2115                            "description": "JSON Schema document",
2116                            "content": { "application/json": {} }
2117                        }
2118                    }
2119                }
2120            },
2121            "/v1/schema/error": {
2122                "get": {
2123                    "summary": "JSON Schema for ApiErrorEnvelope (Draft 2020-12)",
2124                    "operationId": "getErrorSchema",
2125                    "responses": {
2126                        "200": {
2127                            "description": "JSON Schema document",
2128                            "content": { "application/json": {} }
2129                        }
2130                    }
2131                }
2132            },
2133            "/v1/admin": {
2134                "get": {
2135                    "summary": "Admin self-identification (placement, identity, URL)",
2136                    "operationId": "getAdminInfo",
2137                    "description": "Returns the admin actor's identity, proc placement, hostname, and URL. Used for placement verification and operational discovery.",
2138                    "responses": {
2139                        "200": {
2140                            "description": "AdminInfo — admin actor placement metadata",
2141                            "content": {
2142                                "application/json": {
2143                                    "schema": { "$ref": "#/components/schemas/AdminInfo" }
2144                                }
2145                            }
2146                        }
2147                    }
2148                }
2149            },
2150            "/v1/tree": {
2151                "get": {
2152                    "summary": "ASCII topology dump (debug)",
2153                    "operationId": "getTree",
2154                    "responses": {
2155                        "200": {
2156                            "description": "Human-readable topology tree",
2157                            "content": { "text/plain": {} }
2158                        }
2159                    }
2160                }
2161            },
2162            "/v1/config/{proc_reference}": {
2163                "get": {
2164                    "summary": "Config snapshot for a proc",
2165                    "operationId": "getConfig",
2166                    "description": "Returns the effective CONFIG-marked configuration entries from the target process. Routes to ProcAgent (worker procs) or HostAgent (service proc).",
2167                    "parameters": [{
2168                        "name": "proc_reference",
2169                        "in": "path",
2170                        "required": true,
2171                        "description": "URL-encoded proc reference (ProcAddr)",
2172                        "schema": { "type": "string" }
2173                    }],
2174                    "responses": {
2175                        "200": {
2176                            "description": "ConfigDumpResult — sorted list of config entries",
2177                            "content": {
2178                                "application/json": {
2179                                    "schema": {
2180                                        "type": "object",
2181                                        "properties": {
2182                                            "entries": {
2183                                                "type": "array",
2184                                                "items": {
2185                                                    "type": "object",
2186                                                    "properties": {
2187                                                        "name": { "type": "string" },
2188                                                        "value": { "type": "string" },
2189                                                        "default_value": { "type": ["string", "null"] },
2190                                                        "source": { "type": "string" },
2191                                                        "changed_from_default": { "type": "boolean" },
2192                                                        "env_var": { "type": ["string", "null"] }
2193                                                    }
2194                                                }
2195                                            }
2196                                        }
2197                                    }
2198                                }
2199                            }
2200                        },
2201                        "404": error_response("Proc not found or handler not reachable"),
2202                        "500": error_response("Internal error"),
2203                        "504": error_response("Gateway timeout")
2204                    }
2205                }
2206            },
2207            "/v1/pyspy/{proc_reference}": {
2208                "get": {
2209                    "summary": "Py-spy stack dump for a proc",
2210                    "operationId": "getPyspy",
2211                    "description": "Runs py-spy against the target process and returns structured stack traces. Routes to ProcAgent (worker procs) or HostAgent (service proc).",
2212                    "parameters": [{
2213                        "name": "proc_reference",
2214                        "in": "path",
2215                        "required": true,
2216                        "description": "URL-encoded proc reference (ProcAddr)",
2217                        "schema": { "type": "string" }
2218                    }],
2219                    "responses": {
2220                        "200": {
2221                            "description": "PySpyResult — one of Ok, BinaryNotFound, or Failed",
2222                            "content": {
2223                                "application/json": {
2224                                    "schema": { "$ref": "#/components/schemas/PySpyResult" }
2225                                }
2226                            }
2227                        },
2228                        "400": error_response("Bad request (malformed proc reference)"),
2229                        "404": error_response("Proc not found or handler not reachable"),
2230                        "500": error_response("Internal error"),
2231                        "504": error_response("Gateway timeout")
2232                    }
2233                }
2234            },
2235            "/v1/query": {
2236                "post": {
2237                    "summary": "Proxy SQL query to the telemetry dashboard",
2238                    "operationId": "queryProxy",
2239                    "description": "Forwards a SQL query to the Monarch dashboard's DataFusion engine. Requires telemetry_url to be configured.",
2240                    "requestBody": {
2241                        "required": true,
2242                        "content": {
2243                            "application/json": {
2244                                "schema": { "$ref": "#/components/schemas/QueryRequest" }
2245                            }
2246                        }
2247                    },
2248                    "responses": {
2249                        "200": {
2250                            "description": "Query results",
2251                            "content": {
2252                                "application/json": {
2253                                    "schema": { "$ref": "#/components/schemas/QueryResponse" }
2254                                }
2255                            }
2256                        },
2257                        "400": error_response("Bad request (invalid SQL or missing sql field)"),
2258                        "404": error_response("Dashboard not configured"),
2259                        "500": error_response("Internal error"),
2260                        "504": error_response("Gateway timeout")
2261                    }
2262                }
2263            },
2264            "/v1/pyspy_dump/{proc_reference}": {
2265                "post": {
2266                    "summary": "Trigger py-spy dump and store in telemetry",
2267                    "operationId": "pyspyDumpAndStore",
2268                    "description": "Runs py-spy against the target process, stores a successful result in the dashboard's DataFusion pyspy tables, and returns the dump_id. Failed captures return an error without a dump_id.",
2269                    "parameters": [{
2270                        "name": "proc_reference",
2271                        "in": "path",
2272                        "required": true,
2273                        "description": "URL-encoded proc reference (ProcAddr)",
2274                        "schema": { "type": "string" }
2275                    }],
2276                    "responses": {
2277                        "200": {
2278                            "description": "Dump stored successfully",
2279                            "content": {
2280                                "application/json": {
2281                                    "schema": { "$ref": "#/components/schemas/PyspyDumpAndStoreResponse" }
2282                                }
2283                            }
2284                        },
2285                        "400": error_response("Bad request (malformed proc reference)"),
2286                        "404": error_response("Proc or dashboard not found"),
2287                        "500": error_response("Capture, storage, or internal error"),
2288                        "503": error_response("py-spy binary unavailable"),
2289                        "504": error_response("Gateway timeout")
2290                    }
2291                }
2292            }
2293        },
2294        "components": {
2295            "schemas": serde_json::Value::Object(shared_schemas)
2296        }
2297    });
2298
2299    // Insert paths outside the json! macro to avoid hitting the
2300    // serde_json recursion limit.
2301    if let Some(paths) = spec.pointer_mut("/paths").and_then(|v| v.as_object_mut()) {
2302        paths.insert(
2303            "/v1/schema/admin".into(),
2304            serde_json::json!({
2305                "get": {
2306                    "summary": "JSON Schema for AdminInfo (Draft 2020-12)",
2307                    "operationId": "getAdminSchema",
2308                    "responses": {
2309                        "200": {
2310                            "description": "JSON Schema document",
2311                            "content": { "application/json": {} }
2312                        }
2313                    }
2314                }
2315            }),
2316        );
2317        paths.insert(
2318            "/v1/pyspy_profile_svg/{proc_reference}".into(),
2319            serde_json::json!({
2320                "post": {
2321                    "summary": "Profile a proc and return SVG flamegraph",
2322                    "operationId": "pyspyProfileSvg",
2323                    "description": "Runs py-spy record against the target process for the requested duration and returns an SVG flamegraph. Timeout scales with duration_s.",
2324                    "parameters": [{
2325                        "name": "proc_reference",
2326                        "in": "path",
2327                        "required": true,
2328                        "description": "URL-encoded proc reference (ProcAddr)",
2329                        "schema": { "type": "string" }
2330                    }],
2331                    "requestBody": {
2332                        "required": true,
2333                        "content": {
2334                            "application/json": {
2335                                "schema": { "$ref": "#/components/schemas/PySpyProfileOpts" }
2336                            }
2337                        }
2338                    },
2339                    "responses": {
2340                        "200": {
2341                            "description": "SVG flamegraph",
2342                            "content": { "image/svg+xml": {} }
2343                        },
2344                        "400": error_response("Bad request (invalid duration/rate or malformed proc reference)"),
2345                        "404": error_response("Proc not found or handler not reachable"),
2346                        "500": error_response("Internal error (profile failed or SVG generation failed)"),
2347                        "503": error_response("Service unavailable (py-spy not available on target host)"),
2348                        "504": error_response("Gateway timeout (subprocess timed out)")
2349                    }
2350                }
2351            }),
2352        );
2353    }
2354
2355    spec
2356}
2357
2358/// OpenAPI 3.1 spec for the mesh admin API.
2359async fn serve_openapi() -> Result<axum::response::Json<serde_json::Value>, ApiError> {
2360    Ok(axum::response::Json(build_openapi_spec()))
2361}
2362
2363/// Validate and parse a raw proc reference path segment into a
2364/// decoded reference string and `ProcAddr`. Extracted for testability.
2365fn parse_proc_reference(raw: &str) -> Result<(String, ProcAddr), ApiError> {
2366    let trimmed = raw.trim_start_matches('/');
2367    if trimmed.is_empty() {
2368        return Err(ApiError::bad_request("empty proc reference", None));
2369    }
2370    let decoded = urlencoding::decode(trimmed)
2371        .map(|cow| cow.into_owned())
2372        .map_err(|_| {
2373            ApiError::bad_request(
2374                "malformed percent-encoding: decoded bytes are not valid UTF-8",
2375                None,
2376            )
2377        })?;
2378    let proc_id: ProcAddr = decoded
2379        .parse()
2380        .map_err(|e| ApiError::bad_request(format!("invalid proc reference: {}", e), None))?;
2381    Ok((decoded, proc_id))
2382}
2383
2384/// Probe whether an actor is reachable by sending a lightweight
2385/// introspect query bounded by `MESH_ADMIN_QUERY_CHILD_TIMEOUT`.
2386///
2387/// Returns `Ok(true)` if the actor responds, `Ok(false)` if the
2388/// actor is absent or unresponsive (timeout / recv error).
2389async fn probe_actor(
2390    cx: &Instance<()>,
2391    agent_id: &hyperactor::ActorAddr,
2392) -> Result<bool, ApiError> {
2393    let port = agent_id.introspect_port();
2394    let (handle, rx) = open_once_port::<IntrospectResult>(cx);
2395    port.post(
2396        cx,
2397        IntrospectMessage::Query {
2398            view: IntrospectView::Entity,
2399            reply: handle.bind(),
2400        },
2401    );
2402
2403    let timeout = hyperactor_config::global::get(crate::config::MESH_ADMIN_QUERY_CHILD_TIMEOUT);
2404    match tokio::time::timeout(timeout, rx.recv()).await {
2405        Ok(Ok(_)) => Ok(true),
2406        Ok(Err(e)) => {
2407            tracing::debug!(
2408                name = "pyspy_probe_recv_failed",
2409                %agent_id,
2410                error = %e,
2411            );
2412            Ok(false)
2413        }
2414        Err(_elapsed) => {
2415            tracing::debug!(
2416                name = "pyspy_probe_timeout",
2417                %agent_id,
2418            );
2419            Ok(false)
2420        }
2421    }
2422}
2423
2424/// Core py-spy dump logic shared by `pyspy_bridge` and
2425/// `pyspy_dump_and_store`.
2426///
2427/// Typed proc-handler target. Private to this module. The single
2428/// minting point is `route_proc_handler` via `ActorRef::attest`.
2429/// After minting, all sends go through typed `ActorRef::send`.
2430enum ResolvedProcHandler {
2431    Host(ActorRef<HostAgent>),
2432    Proc(ActorRef<ProcAgent>),
2433}
2434
2435impl ResolvedProcHandler {
2436    fn agent_id(&self) -> hyperactor::ActorAddr {
2437        match self {
2438            Self::Host(r) => r.actor_addr().clone(),
2439            Self::Proc(r) => r.actor_addr().clone(),
2440        }
2441    }
2442
2443    async fn pyspy_dump(
2444        &self,
2445        cx: &impl hyperactor::context::Actor,
2446        opts: PySpyOpts,
2447        timeout: std::time::Duration,
2448    ) -> Result<PySpyResult, ApiError> {
2449        let (reply_handle, reply_rx) = open_once_port::<PySpyResult>(cx);
2450        let mut reply_ref = reply_handle.bind();
2451        reply_ref.return_undeliverable(false);
2452        let msg = PySpyDump {
2453            opts,
2454            result: reply_ref,
2455        };
2456        match self {
2457            Self::Host(r) => r.post(cx, msg),
2458            Self::Proc(r) => r.post(cx, msg),
2459        };
2460        tokio::time::timeout(timeout, reply_rx.recv())
2461            .await
2462            .map_err(|_| ApiError {
2463                code: "gateway_timeout".to_string(),
2464                message: "timed out waiting for py-spy dump".to_string(),
2465                details: None,
2466            })?
2467            .map_err(|e| ApiError {
2468                code: "internal_error".to_string(),
2469                message: format!("failed to receive PySpyResult: {}", e),
2470                details: None,
2471            })
2472    }
2473
2474    async fn pyspy_profile(
2475        &self,
2476        cx: &impl hyperactor::context::Actor,
2477        request: ValidatedProfileRequest,
2478        timeout: std::time::Duration,
2479    ) -> Result<PySpyProfileResult, ApiError> {
2480        let (reply_handle, reply_rx) = open_once_port::<PySpyProfileResult>(cx);
2481        let mut reply_ref = reply_handle.bind();
2482        reply_ref.return_undeliverable(false);
2483        let msg = PySpyProfile {
2484            request,
2485            result: reply_ref,
2486        };
2487        match self {
2488            Self::Host(r) => r.post(cx, msg),
2489            Self::Proc(r) => r.post(cx, msg),
2490        };
2491        tokio::time::timeout(timeout, reply_rx.recv())
2492            .await
2493            .map_err(|_| ApiError {
2494                code: "gateway_timeout".to_string(),
2495                message: "timed out waiting for py-spy profile".to_string(),
2496                details: None,
2497            })?
2498            .map_err(|e| ApiError {
2499                code: "internal_error".to_string(),
2500                message: format!("failed to receive PySpyProfileResult: {}", e),
2501                details: None,
2502            })
2503    }
2504
2505    async fn config_dump(
2506        &self,
2507        cx: &impl hyperactor::context::Actor,
2508        timeout: std::time::Duration,
2509    ) -> Result<ConfigDumpResult, ApiError> {
2510        let (reply_handle, reply_rx) = open_once_port::<ConfigDumpResult>(cx);
2511        let mut reply_ref = reply_handle.bind();
2512        reply_ref.return_undeliverable(false);
2513        let msg = ConfigDump { result: reply_ref };
2514        match self {
2515            Self::Host(r) => r.post(cx, msg),
2516            Self::Proc(r) => r.post(cx, msg),
2517        };
2518        tokio::time::timeout(timeout, reply_rx.recv())
2519            .await
2520            .map_err(|_| ApiError {
2521                code: "gateway_timeout".to_string(),
2522                message: "timed out waiting for config dump".to_string(),
2523                details: None,
2524            })?
2525            .map_err(|e| ApiError {
2526                code: "internal_error".to_string(),
2527                message: format!("failed to receive ConfigDumpResult: {}", e),
2528                details: None,
2529            })
2530    }
2531}
2532
2533/// Parse + route + attest. No probe. The single `ActorRef::attest`
2534/// minting point. Used by `config_bridge` which intentionally skips
2535/// the probe (CFG-4).
2536fn route_proc_handler(
2537    host_agents: &HashSet<HostAgentKey>,
2538    raw_proc_reference: &str,
2539) -> Result<ResolvedProcHandler, ApiError> {
2540    let (_proc_reference, proc_id) = parse_proc_reference(raw_proc_reference)?;
2541    let host_agent_id = proc_id.actor_addr(HOST_MESH_AGENT_ACTOR_NAME);
2542    if host_agents.contains(&HostAgentKey::new(&host_agent_id)) {
2543        return Ok(ResolvedProcHandler::Host(ActorRef::attest(host_agent_id)));
2544    }
2545
2546    let proc_agent_id = proc_id.actor_addr(PROC_AGENT_ACTOR_NAME);
2547    Ok(ResolvedProcHandler::Proc(ActorRef::attest(proc_agent_id)))
2548}
2549
2550/// Parse + route + attest + probe (PS-13).
2551async fn resolve_proc_handler(
2552    state: &BridgeState,
2553    raw_proc_reference: &str,
2554) -> Result<ResolvedProcHandler, ApiError> {
2555    let handler = route_proc_handler(&state.host_agents, raw_proc_reference)?;
2556    let cx = &state.bridge_cx;
2557    if !probe_actor(cx, &handler.agent_id()).await? {
2558        return Err(ApiError::not_found(
2559            format!(
2560                "proc does not have a reachable handler ({})",
2561                raw_proc_reference,
2562            ),
2563            None,
2564        ));
2565    }
2566    Ok(handler)
2567}
2568
2569async fn do_pyspy_dump(
2570    state: &BridgeState,
2571    raw_proc_reference: &str,
2572) -> Result<PySpyResult, ApiError> {
2573    let handler = resolve_proc_handler(state, raw_proc_reference).await?;
2574    let timeout = hyperactor_config::global::get(crate::config::MESH_ADMIN_PYSPY_BRIDGE_TIMEOUT);
2575    handler
2576        .pyspy_dump(
2577            &state.bridge_cx,
2578            PySpyOpts {
2579                threads: false,
2580                native: true,
2581                native_all: true,
2582                nonblocking: false,
2583            },
2584            timeout,
2585        )
2586        .await
2587}
2588
2589/// HTTP bridge for py-spy stack dump requests.
2590///
2591/// Parses the proc reference, routes to the appropriate actor
2592/// (ProcAgent on worker procs, HostAgent on the service proc),
2593/// probes for reachability, and sends `PySpyDump` directly.
2594/// See PS-12, PS-13 in `introspect` module doc.
2595async fn pyspy_bridge(
2596    State(state): State<Arc<BridgeState>>,
2597    AxumPath(proc_reference): AxumPath<String>,
2598) -> Result<Json<PySpyResult>, ApiError> {
2599    Ok(Json(do_pyspy_dump(&state, &proc_reference).await?))
2600}
2601
2602async fn do_pyspy_profile(
2603    state: &BridgeState,
2604    raw_proc_reference: &str,
2605    opts: PySpyProfileOpts,
2606) -> Result<PySpyProfileResult, ApiError> {
2607    let max_duration =
2608        hyperactor_config::global::get(crate::config::MESH_ADMIN_PYSPY_MAX_PROFILE_DURATION);
2609    let request = ValidatedProfileRequest::try_new(&opts, max_duration)
2610        .map_err(|msg| ApiError::bad_request(msg, None))?;
2611    let bridge_timeout = request.bridge_timeout();
2612    let handler = resolve_proc_handler(state, raw_proc_reference).await?;
2613    handler
2614        .pyspy_profile(&state.bridge_cx, request, bridge_timeout)
2615        .await
2616}
2617
2618/// HTTP bridge for py-spy profile SVG requests.
2619///
2620/// Accepts `PySpyProfileOpts` as JSON POST body, profiles the target
2621/// process, and returns raw SVG.
2622async fn pyspy_profile_svg(
2623    State(state): State<Arc<BridgeState>>,
2624    AxumPath(proc_reference): AxumPath<String>,
2625    Json(opts): Json<PySpyProfileOpts>,
2626) -> Result<axum::response::Response, ApiError> {
2627    let result = do_pyspy_profile(&state, &proc_reference, opts).await?;
2628    match result {
2629        PySpyProfileResult::Ok { svg, .. } => Ok(axum::response::Response::builder()
2630            .header("content-type", "image/svg+xml")
2631            .body(axum::body::Body::from(svg))
2632            .unwrap()),
2633        PySpyProfileResult::BinaryNotFound { searched } => Err(ApiError {
2634            code: "service_unavailable".to_string(),
2635            message: format!(
2636                "py-spy not available on target host; searched: {}",
2637                searched.join(", ")
2638            ),
2639            details: None,
2640        }),
2641        PySpyProfileResult::TimedOut {
2642            timeout_s, stderr, ..
2643        } => Err(ApiError {
2644            code: "gateway_timeout".to_string(),
2645            message: format!(
2646                "py-spy record subprocess timed out after {}s: {}",
2647                timeout_s,
2648                stderr.trim()
2649            ),
2650            details: None,
2651        }),
2652        PySpyProfileResult::ExitFailure { stderr, .. } => Err(ApiError {
2653            code: "profile_failed".to_string(),
2654            message: stderr,
2655            details: None,
2656        }),
2657        PySpyProfileResult::OutputMissing { pid, binary } => Err(ApiError {
2658            code: "profile_output_unusable".to_string(),
2659            message: format!("py-spy exited 0 but SVG file is missing (pid {pid}, {binary})"),
2660            details: None,
2661        }),
2662        PySpyProfileResult::OutputEmpty { pid, binary } => Err(ApiError {
2663            code: "profile_output_unusable".to_string(),
2664            message: format!("py-spy exited 0 but SVG output is empty (pid {pid}, {binary})"),
2665            details: None,
2666        }),
2667        PySpyProfileResult::OutputReadFailure { error, .. } => Err(ApiError {
2668            code: "internal_error".to_string(),
2669            message: format!("failed to read SVG output: {error}"),
2670            details: None,
2671        }),
2672        PySpyProfileResult::WorkerSpawnFailure { error } => Err(ApiError {
2673            code: "internal_error".to_string(),
2674            message: format!("failed to spawn profile worker actor: {error}"),
2675            details: None,
2676        }),
2677        PySpyProfileResult::SubprocessSpawnFailure { error, .. } => Err(ApiError {
2678            code: "internal_error".to_string(),
2679            message: format!("failed to execute py-spy: {error}"),
2680            details: None,
2681        }),
2682        PySpyProfileResult::WaitFailure { error, .. } => Err(ApiError {
2683            code: "internal_error".to_string(),
2684            message: format!("failed to wait for child: {error}"),
2685            details: None,
2686        }),
2687        PySpyProfileResult::TempDirFailure { error, .. } => Err(ApiError {
2688            code: "internal_error".to_string(),
2689            message: format!("failed to create temp dir: {error}"),
2690            details: None,
2691        }),
2692    }
2693}
2694
2695/// Request body for `POST /v1/query`.
2696#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
2697pub struct QueryRequest {
2698    /// SQL query string.
2699    pub sql: String,
2700}
2701
2702/// Response body from `POST /v1/query`.
2703#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
2704pub struct QueryResponse {
2705    /// Query result rows.
2706    pub rows: serde_json::Value,
2707}
2708
2709/// Request body sent to the dashboard's `/api/pyspy_dump` endpoint.
2710#[derive(Debug, Serialize)]
2711struct StorePyspyDumpRequest {
2712    dump_id: String,
2713    proc_ref: String,
2714    pyspy_result_json: String,
2715}
2716
2717/// Response body from `POST /v1/pyspy_dump/{*proc_reference}`.
2718#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
2719pub struct PyspyDumpAndStoreResponse {
2720    /// Unique identifier for the stored dump.
2721    pub dump_id: String,
2722}
2723
2724fn serialize_storable_pyspy_result(pyspy_result: &PySpyResult) -> Result<String, ApiError> {
2725    match pyspy_result {
2726        PySpyResult::Ok { .. } => serde_json::to_string(pyspy_result).map_err(|e| ApiError {
2727            code: "internal_error".to_string(),
2728            message: format!("failed to serialize PySpyResult: {}", e),
2729            details: None,
2730        }),
2731        PySpyResult::BinaryNotFound { searched } => Err(ApiError {
2732            code: "service_unavailable".to_string(),
2733            message: format!(
2734                "py-spy not available on target host; searched: {}",
2735                searched.join(", ")
2736            ),
2737            details: None,
2738        }),
2739        PySpyResult::Failed {
2740            pid,
2741            binary,
2742            exit_code,
2743            stderr,
2744        } => {
2745            let exit_code =
2746                exit_code.map_or_else(|| "unknown".to_string(), |code| code.to_string());
2747            let stderr = stderr.trim();
2748            let message = if stderr.is_empty() {
2749                format!("py-spy dump failed for pid {pid} using {binary} (exit code {exit_code})")
2750            } else {
2751                format!(
2752                    "py-spy dump failed for pid {pid} using {binary} (exit code {exit_code}): {stderr}"
2753                )
2754            };
2755            Err(ApiError {
2756                code: "pyspy_failed".to_string(),
2757                message,
2758                details: None,
2759            })
2760        }
2761    }
2762}
2763
2764/// Resolve the telemetry URL from bridge state, returning an
2765/// `ApiError` if not configured.
2766fn require_telemetry_url(state: &BridgeState) -> Result<&str, ApiError> {
2767    state.telemetry_url.as_deref().ok_or_else(|| {
2768        ApiError::not_found("dashboard not configured (no telemetry_url provided)", None)
2769    })
2770}
2771
2772/// Proxy SQL queries to the Monarch dashboard's `/api/query`
2773/// endpoint.
2774///
2775/// Requires `telemetry_url` to be set. The request body must
2776/// contain a `sql` field. The dashboard response rows are returned
2777/// verbatim.
2778async fn query_proxy(
2779    State(state): State<Arc<BridgeState>>,
2780    axum::Json(body): axum::Json<QueryRequest>,
2781) -> Result<axum::Json<QueryResponse>, ApiError> {
2782    let telemetry_url = require_telemetry_url(&state)?;
2783
2784    let resp = state
2785        .http_client
2786        .post(format!("{}/api/query", telemetry_url))
2787        .json(&body)
2788        .send()
2789        .await
2790        .map_err(|e| ApiError {
2791            code: "proxy_error".to_string(),
2792            message: format!("failed to proxy query to dashboard: {}", e),
2793            details: None,
2794        })?;
2795
2796    let status = resp.status();
2797    let resp_body = resp.bytes().await.map_err(|e| ApiError {
2798        code: "proxy_error".to_string(),
2799        message: format!("failed to read dashboard response: {}", e),
2800        details: None,
2801    })?;
2802
2803    if !status.is_success() {
2804        // Try to extract error message from dashboard response.
2805        let msg = serde_json::from_slice::<serde_json::Value>(&resp_body)
2806            .ok()
2807            .and_then(|v| v.get("error")?.as_str().map(String::from))
2808            .unwrap_or_else(|| format!("dashboard returned HTTP {status}"));
2809        let code = if status.is_client_error() {
2810            "bad_request"
2811        } else {
2812            "proxy_error"
2813        };
2814        return Err(ApiError {
2815            code: code.to_string(),
2816            message: msg,
2817            details: None,
2818        });
2819    }
2820
2821    let result: QueryResponse = serde_json::from_slice(&resp_body).map_err(|e| ApiError {
2822        code: "proxy_error".to_string(),
2823        message: format!("failed to parse dashboard response: {}", e),
2824        details: None,
2825    })?;
2826
2827    Ok(axum::Json(result))
2828}
2829
2830/// Trigger a py-spy dump and store the result in the dashboard's
2831/// DataFusion pyspy tables.
2832///
2833/// 1. Performs a py-spy dump via `do_pyspy_dump` (same as
2834///    `pyspy_bridge`).
2835/// 2. Rejects unsuccessful captures without generating a dump id.
2836/// 3. POSTs the serialized successful result to the dashboard's
2837///    `/api/pyspy_dump` endpoint for persistent storage.
2838/// 4. Returns the generated dump id.
2839async fn pyspy_dump_and_store(
2840    State(state): State<Arc<BridgeState>>,
2841    AxumPath(proc_reference): AxumPath<String>,
2842) -> Result<axum::Json<PyspyDumpAndStoreResponse>, ApiError> {
2843    let telemetry_url = require_telemetry_url(&state)?;
2844    let pyspy_result = do_pyspy_dump(&state, &proc_reference).await?;
2845
2846    let pyspy_json = serialize_storable_pyspy_result(&pyspy_result)?;
2847    let dump_id = uuid::Uuid::new_v4().to_string();
2848
2849    let store_body = StorePyspyDumpRequest {
2850        dump_id: dump_id.clone(),
2851        proc_ref: proc_reference,
2852        pyspy_result_json: pyspy_json,
2853    };
2854
2855    let store_resp = state
2856        .http_client
2857        .post(format!("{}/api/pyspy_dump", telemetry_url))
2858        .json(&store_body)
2859        .send()
2860        .await
2861        .map_err(|e| ApiError {
2862            code: "proxy_error".to_string(),
2863            message: format!("failed to store pyspy dump in dashboard: {}", e),
2864            details: None,
2865        })?;
2866
2867    if !store_resp.status().is_success() {
2868        return Err(ApiError {
2869            code: "proxy_error".to_string(),
2870            message: format!(
2871                "dashboard rejected pyspy dump store: HTTP {}",
2872                store_resp.status()
2873            ),
2874            details: None,
2875        });
2876    }
2877
2878    Ok(axum::Json(PyspyDumpAndStoreResponse { dump_id }))
2879}
2880
2881/// HTTP bridge for config dump requests.
2882///
2883/// Config dump bridge. No preflight probe — the send + bridge
2884/// timeout handles both absent and busy actors correctly (CFG-4).
2885async fn config_bridge(
2886    State(state): State<Arc<BridgeState>>,
2887    AxumPath(proc_reference): AxumPath<String>,
2888) -> Result<Json<ConfigDumpResult>, ApiError> {
2889    let handler = route_proc_handler(&state.host_agents, &proc_reference)?;
2890    let timeout =
2891        hyperactor_config::global::get(crate::config::MESH_ADMIN_CONFIG_DUMP_BRIDGE_TIMEOUT);
2892    let result = handler.config_dump(&state.bridge_cx, timeout).await?;
2893    Ok(Json(result))
2894}
2895
2896/// Resolve an opaque reference string to a `NodePayload` via the
2897/// actor-based resolver.
2898///
2899/// Implements `GET /v1/{*reference}` for the reference-walking client
2900/// (e.g. the TUI):
2901/// - Decodes the wildcard path segment into the original reference
2902///   string (Axum does not percent-decode `{*reference}` captures).
2903/// - Sends `ResolveReferenceMessage::Resolve` to `MeshAdminAgent` and
2904///   awaits the reply.
2905/// - Maps resolver failures into appropriate `ApiError`s
2906///   (`bad_request`, `not_found`, `gateway_timeout`, or
2907///   `internal_error`).
2908async fn resolve_reference_bridge(
2909    State(state): State<Arc<BridgeState>>,
2910    AxumPath(reference): AxumPath<String>,
2911) -> Result<Json<NodePayloadDto>, ApiError> {
2912    // Axum's wildcard may include a leading slash; strip it.
2913    let reference = reference.trim_start_matches('/');
2914    if reference.is_empty() {
2915        return Err(ApiError::bad_request("empty reference", None));
2916    }
2917    let reference = urlencoding::decode(reference)
2918        .map(|cow| cow.into_owned())
2919        .map_err(|_| {
2920            ApiError::bad_request(
2921                "malformed percent-encoding: decoded bytes are not valid UTF-8",
2922                None,
2923            )
2924        })?;
2925
2926    // Limit concurrent resolves to avoid starving user workloads
2927    // that share this tokio runtime.
2928    let _permit = state.resolve_semaphore.try_acquire().map_err(|_| {
2929        tracing::warn!("mesh admin: rejecting resolve request (503): too many concurrent requests");
2930        ApiError {
2931            code: "service_unavailable".to_string(),
2932            message: "too many concurrent introspection requests".to_string(),
2933            details: None,
2934        }
2935    })?;
2936
2937    let cx = &state.bridge_cx;
2938    let resolve_start = std::time::Instant::now();
2939    let response = tokio::time::timeout(
2940        hyperactor_config::global::get(crate::config::MESH_ADMIN_SINGLE_HOST_TIMEOUT),
2941        state.admin_ref.resolve(cx, reference.clone()),
2942    )
2943    .await
2944    .map_err(|_| {
2945        tracing::warn!(
2946            reference = %reference,
2947            elapsed_ms = resolve_start.elapsed().as_millis() as u64,
2948            "mesh admin: resolve timed out (gateway_timeout)",
2949        );
2950        ApiError {
2951            code: "gateway_timeout".to_string(),
2952            message: "timed out resolving reference".to_string(),
2953            details: None,
2954        }
2955    })?
2956    .map_err(|e| ApiError {
2957        code: "internal_error".to_string(),
2958        message: format!("failed to resolve reference: {}", e),
2959        details: None,
2960    })?;
2961
2962    match response.0 {
2963        Ok(payload) => Ok(Json(NodePayloadDto::from(payload))),
2964        Err(error) => Err(ApiError::not_found(error, None)),
2965    }
2966}
2967
2968// TODO: MESH_ADMIN_TREE_TIMEOUT is applied per-call, not as a total
2969// budget. On a mesh with N hosts and M procs, the worst case is
2970// N*(1+M) sequential calls each up to 10s. This should use a single
2971// deadline for the entire walk.
2972/// `GET /v1/tree` — ASCII topology dump.
2973///
2974/// Walks the reference graph starting from `"root"`, resolving each
2975/// host and its proc children, and formats the result as a
2976/// human-readable ASCII tree suitable for quick `curl` inspection.
2977/// Each line includes a clickable URL for drilling into that node via
2978/// the reference API. Built on top of the same
2979/// `ResolveReferenceMessage` protocol used by the TUI.
2980///
2981/// Output format:
2982/// ```text
2983/// unix:@hash  ->  https://host:port/v1/...  (or http:// in OSS)
2984/// ├── service  ->  https://host:port/v1/...
2985/// │   ├── agent[0]  ->  https://host:port/v1/...
2986/// │   └── client[0]  ->  https://host:port/v1/...
2987/// ├── local  ->  https://host:port/v1/...
2988/// └── philosophers_0  ->  https://host:port/v1/...
2989///     ├── agent[0]  ->  https://host:port/v1/...
2990///     └── philosopher[0]  ->  https://host:port/v1/...
2991/// ```
2992async fn tree_dump(
2993    State(state): State<Arc<BridgeState>>,
2994    headers: axum::http::header::HeaderMap,
2995) -> Result<String, ApiError> {
2996    // Limit concurrent resolves to avoid starving user workloads.
2997    let _permit = state.resolve_semaphore.try_acquire().map_err(|_| {
2998        tracing::warn!(
2999            "mesh admin: rejecting tree_dump request (503): too many concurrent requests"
3000        );
3001        ApiError {
3002            code: "service_unavailable".to_string(),
3003            message: "too many concurrent introspection requests".to_string(),
3004            details: None,
3005        }
3006    })?;
3007
3008    let cx = &state.bridge_cx;
3009
3010    // Build base URL from the Host header for clickable links.
3011    let host = headers
3012        .get("host")
3013        .and_then(|v| v.to_str().ok())
3014        .unwrap_or("localhost");
3015    let scheme = headers
3016        .get("x-forwarded-proto")
3017        .and_then(|v| v.to_str().ok())
3018        .unwrap_or("http");
3019    let base_url = format!("{}://{}", scheme, host);
3020
3021    // Resolve root.
3022    let root_resp = tokio::time::timeout(
3023        hyperactor_config::global::get(crate::config::MESH_ADMIN_TREE_TIMEOUT),
3024        state.admin_ref.resolve(cx, "root".to_string()),
3025    )
3026    .await
3027    .map_err(|_| ApiError {
3028        code: "gateway_timeout".to_string(),
3029        message: "timed out resolving root".to_string(),
3030        details: None,
3031    })?
3032    .map_err(|e| ApiError {
3033        code: "internal_error".to_string(),
3034        message: format!("failed to resolve root: {}", e),
3035        details: None,
3036    })?;
3037
3038    let root = root_resp.0.map_err(|e| ApiError {
3039        code: "internal_error".to_string(),
3040        message: e,
3041        details: None,
3042    })?;
3043
3044    let mut output = String::new();
3045
3046    // Resolve each root child. Hosts get the full host→proc→actor
3047    // subtree; non-host children (e.g. the root client actor) are
3048    // rendered as single leaf lines.
3049    for child_ref in &root.children {
3050        let child_ref_str = child_ref.to_string();
3051        let resp = tokio::time::timeout(
3052            hyperactor_config::global::get(crate::config::MESH_ADMIN_TREE_TIMEOUT),
3053            state.admin_ref.resolve(cx, child_ref_str.clone()),
3054        )
3055        .await;
3056
3057        let payload = match resp {
3058            Ok(Ok(r)) => r.0.ok(),
3059            _ => None,
3060        };
3061
3062        match payload {
3063            Some(node) if matches!(node.properties, NodeProperties::Host { .. }) => {
3064                let header = match &node.properties {
3065                    NodeProperties::Host { addr, .. } => addr.clone(),
3066                    _ => child_ref_str.clone(),
3067                };
3068                let host_url = format!("{}/v1/{}", base_url, urlencoding::encode(&child_ref_str));
3069                output.push_str(&format!("{}  ->  {}\n", header, host_url));
3070
3071                let num_procs = node.children.len();
3072                for (i, proc_ref) in node.children.iter().enumerate() {
3073                    let proc_ref_str = proc_ref.to_string();
3074                    let is_last_proc = i == num_procs - 1;
3075                    let proc_connector = if is_last_proc {
3076                        "└── "
3077                    } else {
3078                        "├── "
3079                    };
3080                    let proc_name = derive_tree_label(proc_ref);
3081                    let proc_url =
3082                        format!("{}/v1/{}", base_url, urlencoding::encode(&proc_ref_str));
3083                    output.push_str(&format!(
3084                        "{}{}  ->  {}\n",
3085                        proc_connector, proc_name, proc_url
3086                    ));
3087
3088                    let proc_resp = tokio::time::timeout(
3089                        hyperactor_config::global::get(crate::config::MESH_ADMIN_TREE_TIMEOUT),
3090                        state.admin_ref.resolve(cx, proc_ref_str),
3091                    )
3092                    .await;
3093                    let proc_payload = match proc_resp {
3094                        Ok(Ok(r)) => r.0.ok(),
3095                        _ => None,
3096                    };
3097                    if let Some(proc_node) = proc_payload {
3098                        let num_actors = proc_node.children.len();
3099                        let child_prefix = if is_last_proc { "    " } else { "│   " };
3100                        for (j, actor_ref) in proc_node.children.iter().enumerate() {
3101                            let actor_ref_str = actor_ref.to_string();
3102                            let actor_connector = if j == num_actors - 1 {
3103                                "└── "
3104                            } else {
3105                                "├── "
3106                            };
3107                            let actor_label = derive_actor_label(actor_ref);
3108                            let actor_url =
3109                                format!("{}/v1/{}", base_url, urlencoding::encode(&actor_ref_str));
3110                            output.push_str(&format!(
3111                                "{}{}{}  ->  {}\n",
3112                                child_prefix, actor_connector, actor_label, actor_url
3113                            ));
3114                        }
3115                    }
3116                }
3117                output.push('\n');
3118            }
3119            Some(node) if matches!(node.properties, NodeProperties::Proc { .. }) => {
3120                let proc_name = match &node.properties {
3121                    NodeProperties::Proc { proc_name, .. } => proc_name.clone(),
3122                    _ => child_ref_str.clone(),
3123                };
3124                let proc_url = format!("{}/v1/{}", base_url, urlencoding::encode(&child_ref_str));
3125                output.push_str(&format!("{}  ->  {}\n", proc_name, proc_url));
3126
3127                let num_actors = node.children.len();
3128                for (j, actor_ref) in node.children.iter().enumerate() {
3129                    let actor_ref_str = actor_ref.to_string();
3130                    let actor_connector = if j == num_actors - 1 {
3131                        "└── "
3132                    } else {
3133                        "├── "
3134                    };
3135                    let actor_label = derive_actor_label(actor_ref);
3136                    let actor_url =
3137                        format!("{}/v1/{}", base_url, urlencoding::encode(&actor_ref_str));
3138                    output.push_str(&format!(
3139                        "{}{}  ->  {}\n",
3140                        actor_connector, actor_label, actor_url
3141                    ));
3142                }
3143                output.push('\n');
3144            }
3145            Some(_node) => {
3146                let label = derive_actor_label(child_ref);
3147                let url = format!("{}/v1/{}", base_url, urlencoding::encode(&child_ref_str));
3148                output.push_str(&format!("{}  ->  {}\n\n", label, url));
3149            }
3150            _ => {
3151                output.push_str(&format!("{} (unreachable)\n\n", child_ref));
3152            }
3153        }
3154    }
3155    Ok(output)
3156}
3157
3158/// Derive a short display label from a reference string for the ASCII
3159/// tree.
3160///
3161/// Extracts the proc name — the meaningful identifier for tree
3162/// display — from the various reference formats emitted by
3163/// `HostAgent`'s children list:
3164///
3165/// - System proc ref `"[system] unix:@hash,service"` → `"service"`
3166/// - ProcAgent ActorAddr `"unix:@hash,my_proc,agent[0]"` →
3167///   `"my_proc"`
3168/// - Bare ProcAddr `"unix:@hash,my_proc"` → `"my_proc"`
3169///
3170/// Note: `ActorAddr::Display` for `ProcAddr` uses commas as
3171/// separators (`proc_id,actor_name[idx]`), not slashes.
3172fn derive_tree_label(node_ref: &crate::introspect::NodeRef) -> String {
3173    match node_ref {
3174        crate::introspect::NodeRef::Root => "root".to_string(),
3175        crate::introspect::NodeRef::Host(id) => id.proc_addr().id().to_string(),
3176        crate::introspect::NodeRef::Proc(id) => id.id().to_string(),
3177        crate::introspect::NodeRef::Actor(id) => {
3178            format!("{}[{}]", id.log_name(), id.uid())
3179        }
3180    }
3181}
3182
3183fn derive_actor_label(node_ref: &crate::introspect::NodeRef) -> String {
3184    match node_ref {
3185        crate::introspect::NodeRef::Root => "root".to_string(),
3186        crate::introspect::NodeRef::Host(id) => id.log_name().to_string(),
3187        crate::introspect::NodeRef::Proc(id) => id.id().to_string(),
3188        crate::introspect::NodeRef::Actor(id) => {
3189            format!("{}[{}]", id.log_name(), id.uid())
3190        }
3191    }
3192}
3193
3194// -- Admin handle type discrimination --
3195
3196/// A handle scheme that requires a publication-based lookup to resolve
3197/// to a concrete admin URL.
3198///
3199/// Only `Mast` is defined today. The nested-enum shape allows future
3200/// scheduler-specific variants (Slurm, K8s, etc.) to be added without
3201/// changing `AdminHandle`.
3202#[non_exhaustive]
3203pub enum PublishedHandle {
3204    /// `mast_conda:///<job-name>` — requires publication-based discovery.
3205    Mast(String),
3206}
3207
3208impl PublishedHandle {
3209    /// Resolve a published handle to a concrete admin URL.
3210    ///
3211    /// All published-handle schemes return an explicit error today.
3212    /// When real publication lookup is implemented, dispatch by variant here.
3213    pub async fn resolve(self, _port_override: Option<u16>) -> anyhow::Result<String> {
3214        anyhow::bail!(
3215            "publication-based admin handle resolution is not yet implemented: \
3216             mesh admin placement has moved to the caller's local proc. \
3217             Discover the admin URL from startup output or another \
3218             launch-time publication instead."
3219        )
3220    }
3221}
3222
3223/// A handle for locating the mesh admin server.
3224///
3225/// Parse a user-supplied address string with [`AdminHandle::parse`]
3226/// and resolve it to a concrete URL with [`AdminHandle::resolve`].
3227#[non_exhaustive]
3228pub enum AdminHandle {
3229    /// Already-resolved URL (e.g. `https://host:1729`).
3230    Url(String),
3231    /// Handle that requires a publication lookup. Currently unresolvable.
3232    Published(PublishedHandle),
3233    /// Scheme or format that is not recognized.
3234    Unsupported(String),
3235}
3236
3237impl AdminHandle {
3238    /// Parse an address string into an `AdminHandle`.
3239    ///
3240    /// Uses `url` crate parsing. Known publication-handle prefixes
3241    /// (`mast_conda:///`) are classified as `Published`. `http`/`https`
3242    /// scheme URLs are `Url`. Bare `host:port` inputs (no scheme) are
3243    /// inferred as `https://host:port` and classified as `Url` — this
3244    /// preserves existing TUI behavior where `--addr myhost:1729` is a
3245    /// valid input. Everything else is `Unsupported`.
3246    pub fn parse(addr: &str) -> Self {
3247        // Check known publication handle prefixes first.
3248        if addr.starts_with("mast_conda:///") {
3249            return AdminHandle::Published(PublishedHandle::Mast(addr.to_string()));
3250        }
3251        // Strict URL parse — only http/https accepted.
3252        if let Ok(parsed) = url::Url::parse(addr)
3253            && matches!(parsed.scheme(), "http" | "https")
3254        {
3255            return AdminHandle::Url(addr.to_string());
3256        }
3257        // Infer https:// for bare host:port inputs (e.g. "myhost:1729").
3258        // This preserves the TUI's documented --addr behavior.
3259        let with_scheme = format!("https://{}", addr);
3260        if let Ok(parsed) = url::Url::parse(&with_scheme)
3261            && parsed.host_str().is_some()
3262            && parsed.port().is_some()
3263        {
3264            return AdminHandle::Url(with_scheme);
3265        }
3266        AdminHandle::Unsupported(addr.to_string())
3267    }
3268
3269    /// Resolve to a concrete admin base URL.
3270    ///
3271    /// `port_override` is retained to preserve existing call surfaces
3272    /// but is intentionally unused until real publication lookup is
3273    /// implemented.
3274    pub async fn resolve(self, port_override: Option<u16>) -> anyhow::Result<String> {
3275        match self {
3276            AdminHandle::Url(url) => Ok(url),
3277            AdminHandle::Published(h) => h.resolve(port_override).await,
3278            AdminHandle::Unsupported(s) => anyhow::bail!(
3279                "unrecognized admin handle '{}': expected https://host:port or mast_conda:///job",
3280                s
3281            ),
3282        }
3283    }
3284}
3285
3286/// Resolve a `mast_conda:///<job-name>` handle into an admin base URL.
3287///
3288/// **Disabled.** Mesh admin placement has moved to the caller's local
3289/// proc. Delegates to [`AdminHandle::Published`] + [`PublishedHandle::resolve`].
3290/// Kept as a stable API shim; do not remove.
3291pub async fn resolve_mast_handle(
3292    handle: &str,
3293    port_override: Option<u16>,
3294) -> anyhow::Result<String> {
3295    AdminHandle::Published(PublishedHandle::Mast(handle.to_string()))
3296        .resolve(port_override)
3297        .await
3298}
3299
3300/// Cert-aware advertised host selection for wildcard binds.
3301///
3302/// The server advertises one URL. For wildcard binds, this module
3303/// generates candidate hosts from environment sources and picks
3304/// the first candidate covered by the loaded server cert's SAN
3305/// set. This ensures the advertised URL is always consistent with
3306/// the certificate the server presents.
3307mod advertised_host {
3308    use std::net::IpAddr;
3309
3310    /// An identity that can appear as a cert SAN entry.
3311    #[derive(Debug, PartialEq, Eq)]
3312    pub(super) enum SanIdentity {
3313        Ip(IpAddr),
3314        Dns(String),
3315    }
3316
3317    /// Choose the advertised host for a wildcard-bind admin URL.
3318    ///
3319    /// Candidates (in preference order):
3320    /// 1. `hostname::get()` (preferred — human-readable, no
3321    ///    brackets in URLs)
3322    /// 2. `host_ipv6_address()` (Meta: TW metadata → fbwhoami →
3323    ///    local_ipv6)
3324    ///
3325    /// The first candidate whose identity is covered by the loaded
3326    /// server cert's SANs wins. If no cert is available or no
3327    /// candidate matches, falls back to hostname.
3328    pub(super) fn from_cert_sans() -> String {
3329        let hostname = hostname::get()
3330            .unwrap_or_else(|_| "localhost".into())
3331            .into_string()
3332            .unwrap_or_else(|_| "localhost".to_string());
3333
3334        // (url_display_form, identity_to_match_against_cert)
3335        // Prefer DNS names over IPs — more readable, no brackets
3336        // in URLs, more stable across container restarts.
3337        let mut candidates: Vec<(String, SanIdentity)> = Vec::new();
3338
3339        // Candidate 1: hostname (preferred if cert covers it).
3340        candidates.push((hostname.clone(), SanIdentity::Dns(hostname.clone())));
3341
3342        // Candidate 2: host IPv6 address (Meta environments).
3343        #[cfg(fbcode_build)]
3344        if let Ok(ip_str) = hyperactor::meta::host_ip::host_ipv6_address()
3345            && let Ok(ip) = ip_str.parse::<IpAddr>()
3346        {
3347            candidates.push((format!("[{}]", ip), SanIdentity::Ip(ip)));
3348        }
3349
3350        let cert_sans = load_cert_sans();
3351        let chosen = pick_candidate(&candidates, &cert_sans, &hostname);
3352
3353        if chosen != hostname && !cert_sans.is_empty() {
3354            tracing::info!("admin URL host '{}' matches cert SAN", chosen);
3355        } else if !cert_sans.is_empty() && !candidates.iter().any(|(_, id)| cert_sans.contains(id))
3356        {
3357            tracing::warn!(
3358                "no admin URL candidate matched cert SANs; falling back to hostname '{}'",
3359                hostname,
3360            );
3361        }
3362
3363        chosen
3364    }
3365
3366    /// Extract SAN entries from the server cert PEM bundle.
3367    ///
3368    /// Probes cert bundles in the same source order as
3369    /// `try_tls_acceptor_with_pem_bundle`, parses the leaf cert with
3370    /// `x509_parser`, and returns SAN DNS names and IP addresses.
3371    /// Returns empty if no cert is available or parsing fails.
3372    fn load_cert_sans() -> Vec<SanIdentity> {
3373        use std::io::BufReader;
3374
3375        use x509_parser::prelude::*;
3376
3377        let bundle = match hyperactor::channel::try_tls_pem_bundle() {
3378            Some(b) => b,
3379            None => return Vec::new(),
3380        };
3381
3382        let cert_pem = match bundle.cert.reader() {
3383            Ok(r) => {
3384                let mut buf = Vec::new();
3385                if std::io::Read::read_to_end(&mut BufReader::new(r), &mut buf).is_err() {
3386                    return Vec::new();
3387                }
3388                buf
3389            }
3390            Err(_) => return Vec::new(),
3391        };
3392
3393        let mut cursor = &cert_pem[..];
3394        let certs: Vec<_> = rustls_pemfile::certs(&mut cursor)
3395            .filter_map(|r| r.ok())
3396            .collect();
3397
3398        let leaf_der = match certs.first() {
3399            Some(c) => c,
3400            None => return Vec::new(),
3401        };
3402
3403        let (_, cert) = match X509Certificate::from_der(leaf_der.as_ref()) {
3404            Ok(parsed) => parsed,
3405            Err(e) => {
3406                tracing::warn!("failed to parse leaf cert for SAN extraction: {}", e);
3407                return Vec::new();
3408            }
3409        };
3410
3411        let mut sans = Vec::new();
3412        if let Ok(Some(san_ext)) = cert.subject_alternative_name() {
3413            for name in &san_ext.value.general_names {
3414                match name {
3415                    GeneralName::DNSName(dns) => {
3416                        sans.push(SanIdentity::Dns(dns.to_string()));
3417                    }
3418                    GeneralName::IPAddress(bytes) => {
3419                        let ip = match bytes.len() {
3420                            4 => IpAddr::from(<[u8; 4]>::try_from(*bytes).unwrap()),
3421                            16 => IpAddr::from(<[u8; 16]>::try_from(*bytes).unwrap()),
3422                            _ => continue,
3423                        };
3424                        sans.push(SanIdentity::Ip(ip));
3425                    }
3426                    _ => {}
3427                }
3428            }
3429        }
3430
3431        sans
3432    }
3433
3434    /// Pick the first candidate covered by the given SAN set.
3435    /// Extracted from `from_cert_sans` for direct unit testing.
3436    fn pick_candidate(
3437        candidates: &[(String, SanIdentity)],
3438        cert_sans: &[SanIdentity],
3439        fallback: &str,
3440    ) -> String {
3441        if cert_sans.is_empty() {
3442            return fallback.to_string();
3443        }
3444        for (url_host, identity) in candidates {
3445            if cert_sans.iter().any(|san| san == identity) {
3446                return url_host.clone();
3447            }
3448        }
3449        fallback.to_string()
3450    }
3451
3452    #[cfg(test)]
3453    mod tests {
3454        use std::net::IpAddr;
3455        use std::net::Ipv4Addr;
3456        use std::net::Ipv6Addr;
3457
3458        use super::*;
3459
3460        #[test]
3461        fn cert_covers_hostname_only_picks_hostname() {
3462            let candidates = vec![
3463                ("myhost".to_string(), SanIdentity::Dns("myhost".to_string())),
3464                (
3465                    "[::1]".to_string(),
3466                    SanIdentity::Ip(IpAddr::V6(Ipv6Addr::LOCALHOST)),
3467                ),
3468            ];
3469            let sans = vec![SanIdentity::Dns("myhost".to_string())];
3470            assert_eq!(pick_candidate(&candidates, &sans, "fallback"), "myhost");
3471        }
3472
3473        #[test]
3474        fn cert_covers_ip_only_picks_ip() {
3475            let ip = IpAddr::V6("2803:6084:3894:2b36:b5d3:11ef:400:0".parse().unwrap());
3476            let candidates = vec![
3477                ("myhost".to_string(), SanIdentity::Dns("myhost".to_string())),
3478                (format!("[{}]", ip), SanIdentity::Ip(ip)),
3479            ];
3480            let sans = vec![SanIdentity::Ip(ip)];
3481            assert_eq!(
3482                pick_candidate(&candidates, &sans, "fallback"),
3483                format!("[{}]", ip)
3484            );
3485        }
3486
3487        #[test]
3488        fn cert_covers_both_prefers_hostname() {
3489            let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
3490            let candidates = vec![
3491                ("myhost".to_string(), SanIdentity::Dns("myhost".to_string())),
3492                ("10.0.0.1".to_string(), SanIdentity::Ip(ip)),
3493            ];
3494            let sans = vec![SanIdentity::Dns("myhost".to_string()), SanIdentity::Ip(ip)];
3495            assert_eq!(pick_candidate(&candidates, &sans, "fallback"), "myhost");
3496        }
3497
3498        #[test]
3499        fn no_sans_returns_fallback() {
3500            let candidates = vec![("myhost".to_string(), SanIdentity::Dns("myhost".to_string()))];
3501            assert_eq!(pick_candidate(&candidates, &[], "fallback"), "fallback");
3502        }
3503
3504        #[test]
3505        fn no_candidate_matches_returns_fallback() {
3506            let candidates = vec![("myhost".to_string(), SanIdentity::Dns("myhost".to_string()))];
3507            let sans = vec![SanIdentity::Dns("otherhost".to_string())];
3508            assert_eq!(pick_candidate(&candidates, &sans, "fallback"), "fallback");
3509        }
3510    }
3511}
3512
3513#[cfg(test)]
3514mod tests {
3515    use std::net::SocketAddr;
3516
3517    use hyperactor::ProcId;
3518    use hyperactor::channel::ChannelAddr;
3519    use hyperactor::id::Label;
3520    use hyperactor::testing::ids::test_proc_id_with_addr;
3521
3522    use super::*;
3523    use crate::host::SERVICE_PROC_NAME;
3524    use crate::mesh_id::ResourceId;
3525    use crate::pyspy::PySpyCaptureMode;
3526
3527    // Integration tests that spawn MeshAdminAgent must pass
3528    // `Some("[::]:0".parse().unwrap())` as the admin_addr to get an
3529    // ephemeral port. The default (`None`) reads MESH_ADMIN_ADDR
3530    // config which is `[::]:1729` — a fixed port that causes bind
3531    // conflicts when tests run concurrently.
3532
3533    #[test]
3534    fn failed_pyspy_dump_is_rejected_before_storage() {
3535        let error = serialize_storable_pyspy_result(&PySpyResult::Failed {
3536            pid: 1234,
3537            binary: "py-spy".to_string(),
3538            exit_code: Some(1),
3539            stderr: "capture failed".to_string(),
3540        })
3541        .expect_err("a failed capture must not be sent to storage");
3542
3543        assert_eq!(error.code, "pyspy_failed");
3544        assert_eq!(
3545            error.message,
3546            "py-spy dump failed for pid 1234 using py-spy (exit code 1): capture failed"
3547        );
3548        assert_eq!(
3549            error.into_response().status(),
3550            StatusCode::INTERNAL_SERVER_ERROR
3551        );
3552    }
3553
3554    #[test]
3555    fn missing_pyspy_binary_is_rejected_before_storage() {
3556        let error = serialize_storable_pyspy_result(&PySpyResult::BinaryNotFound {
3557            searched: vec!["configured-py-spy".to_string(), "py-spy".to_string()],
3558        })
3559        .expect_err("a missing binary must not be sent to storage");
3560
3561        assert_eq!(error.code, "service_unavailable");
3562        assert_eq!(
3563            error.message,
3564            "py-spy not available on target host; searched: configured-py-spy, py-spy"
3565        );
3566        assert_eq!(
3567            error.into_response().status(),
3568            StatusCode::SERVICE_UNAVAILABLE
3569        );
3570    }
3571
3572    #[test]
3573    fn successful_pyspy_dump_is_serialized_for_storage() {
3574        let result = PySpyResult::Ok {
3575            pid: 1234,
3576            binary: "py-spy".to_string(),
3577            capture_mode: PySpyCaptureMode::PythonOnly,
3578            stack_traces: vec![],
3579            warnings: vec![],
3580        };
3581
3582        let serialized = serialize_storable_pyspy_result(&result)
3583            .expect("a successful capture should be sent to storage");
3584
3585        assert_eq!(
3586            serde_json::from_str::<PySpyResult>(&serialized).unwrap(),
3587            result
3588        );
3589    }
3590
3591    #[test]
3592    fn mesh_admin_access_instructions_print_file_tls_paths() {
3593        let bundle = PemBundle {
3594            ca: Pem::File("/tmp/custom-ca.pem".into()),
3595            cert: Pem::File("/tmp/custom-cert.pem".into()),
3596            key: Pem::File("/tmp/custom-key.pem".into()),
3597        };
3598
3599        let instructions =
3600            mesh_admin_access_instructions("https://admin.example.com", Some(&bundle));
3601
3602        assert!(instructions.contains(
3603            "Root node:     curl --cacert /tmp/custom-ca.pem --cert /tmp/custom-cert.pem --key /tmp/custom-key.pem https://admin.example.com/v1/root"
3604        ));
3605    }
3606
3607    #[test]
3608    fn mesh_admin_access_instructions_print_static_tls_paths() {
3609        let bundle = PemBundle {
3610            ca: Pem::StaticPath("/etc/hyperactor/tls/ca.crt"),
3611            cert: Pem::StaticPath("/etc/hyperactor/tls/tls.crt"),
3612            key: Pem::StaticPath("/etc/hyperactor/tls/tls.key"),
3613        };
3614
3615        let instructions =
3616            mesh_admin_access_instructions("https://admin.example.com", Some(&bundle));
3617
3618        assert!(instructions.contains(
3619            "Root node:     curl --cacert /etc/hyperactor/tls/ca.crt --cert /etc/hyperactor/tls/tls.crt --key /etc/hyperactor/tls/tls.key https://admin.example.com/v1/root"
3620        ));
3621    }
3622
3623    #[test]
3624    fn mesh_admin_access_instructions_do_not_render_pem_values_as_curl() {
3625        let bundle = PemBundle {
3626            ca: Pem::Value(b"ca".to_vec()),
3627            cert: Pem::File("/tmp/custom-cert.pem".into()),
3628            key: Pem::File("/tmp/custom-key.pem".into()),
3629        };
3630
3631        let instructions =
3632            mesh_admin_access_instructions("https://admin.example.com", Some(&bundle));
3633
3634        assert!(instructions.contains("Root node:     https://admin.example.com/v1/root"));
3635        assert!(!instructions.contains("Root node:     curl"));
3636    }
3637
3638    #[test]
3639    fn mesh_admin_access_instructions_print_plain_http_curl() {
3640        let instructions = mesh_admin_access_instructions("http://localhost:1729", None);
3641
3642        assert!(instructions.contains("Root node:     curl http://localhost:1729/v1/root"));
3643    }
3644
3645    /// Minimal introspectable actor for tests. The `#[export]`
3646    /// attribute generates `Named + Referable + Binds` so that
3647    /// `handle.bind()` registers the `IntrospectMessage` port for
3648    /// remote delivery.
3649    #[derive(Debug)]
3650    #[hyperactor::export(handlers = [])]
3651    struct TestIntrospectableActor;
3652    impl Actor for TestIntrospectableActor {}
3653
3654    // Verifies that MeshAdminAgent::build_root_payload constructs the
3655    // expected root node: identity/root metadata, correct Root
3656    // properties (num_hosts), and child links populated with the
3657    // stringified IDs of the configured host mesh-agent ActorRefs.
3658    #[test]
3659    fn test_build_root_payload() {
3660        let addr1: SocketAddr = "127.0.0.1:9001".parse().unwrap();
3661        let addr2: SocketAddr = "127.0.0.1:9002".parse().unwrap();
3662
3663        let proc1 = test_proc_id_with_addr(ChannelAddr::Tcp(addr1), "host1");
3664        let proc2 = test_proc_id_with_addr(ChannelAddr::Tcp(addr2), "host2");
3665
3666        let actor_id1 = proc1.actor_addr("mesh_agent");
3667        let actor_id2 = proc2.actor_addr("mesh_agent");
3668
3669        let ref1: ActorRef<HostAgent> = ActorRef::attest(actor_id1.clone());
3670        let ref2: ActorRef<HostAgent> = ActorRef::attest(actor_id2.clone());
3671
3672        let agent = MeshAdminAgent::new(
3673            vec![("host_a".to_string(), ref1), ("host_b".to_string(), ref2)],
3674            None,
3675            None,
3676            None,
3677        );
3678
3679        let payload = agent.build_root_payload();
3680        assert_eq!(payload.identity, crate::introspect::NodeRef::Root);
3681        assert_eq!(payload.parent, None);
3682        assert!(matches!(
3683            payload.properties,
3684            NodeProperties::Root { num_hosts: 2, .. }
3685        ));
3686        assert_eq!(payload.children.len(), 2);
3687        assert!(
3688            payload
3689                .children
3690                .contains(&crate::introspect::NodeRef::Host(actor_id1.clone()))
3691        );
3692        assert!(
3693            payload
3694                .children
3695                .contains(&crate::introspect::NodeRef::Host(actor_id2.clone()))
3696        );
3697
3698        // Verify root properties derived from attrs.
3699        match &payload.properties {
3700            NodeProperties::Root {
3701                num_hosts,
3702                started_by,
3703                system_children,
3704                ..
3705            } => {
3706                assert_eq!(*num_hosts, 2);
3707                assert!(!started_by.is_empty());
3708                // LC-1: root system_children is always empty.
3709                assert!(
3710                    system_children.is_empty(),
3711                    "LC-1: root system_children must be empty"
3712                );
3713            }
3714            other => panic!("expected Root, got {:?}", other),
3715        }
3716    }
3717
3718    // Two host refs with distinct identities at one channel address.
3719    fn hosts_at_one_address() -> (ChannelAddr, ActorRef<HostAgent>, ActorRef<HostAgent>) {
3720        let addr = ChannelAddr::Tcp("10.0.0.1:26600".parse().unwrap());
3721        let a =
3722            ActorRef::attest(test_proc_id_with_addr(addr.clone(), "a").actor_addr("host_agent"));
3723        let b =
3724            ActorRef::attest(test_proc_id_with_addr(addr.clone(), "b").actor_addr("host_agent"));
3725        (addr, a, b)
3726    }
3727
3728    fn host_key(host: &ActorRef<HostAgent>) -> HostAgentKey {
3729        HostAgentKey::new(host.actor_addr())
3730    }
3731
3732    fn procs_at(addr: &ChannelAddr, names: &[&str]) -> HashSet<ProcAddr> {
3733        names
3734            .iter()
3735            .map(|name| test_proc_id_with_addr(addr.clone(), name))
3736            .collect()
3737    }
3738
3739    fn admin_for(addr: &ChannelAddr, hosts: &[&ActorRef<HostAgent>]) -> MeshAdminAgent {
3740        MeshAdminAgent::new(
3741            hosts
3742                .iter()
3743                .map(|host| (addr.to_string(), (*host).clone()))
3744                .collect(),
3745            None,
3746            None,
3747            None,
3748        )
3749    }
3750
3751    // A client attached through a gateway shares the gateway host's
3752    // channel address. Both hosts are still listed at the root.
3753    #[test]
3754    fn test_root_lists_hosts_sharing_an_address() {
3755        let (addr, a, b) = hosts_at_one_address();
3756        let agent = admin_for(&addr, &[&a, &b]);
3757
3758        let payload = agent.build_root_payload();
3759        assert!(matches!(
3760            payload.properties,
3761            NodeProperties::Root { num_hosts: 2, .. }
3762        ));
3763        assert_eq!(
3764            payload.children,
3765            vec![
3766                crate::introspect::NodeRef::Host(a.actor_addr().clone()),
3767                crate::introspect::NodeRef::Host(b.actor_addr().clone()),
3768            ]
3769        );
3770    }
3771
3772    #[test]
3773    fn test_host_snapshot_replaces_only_that_hosts_procs() {
3774        let (addr, a, b) = hosts_at_one_address();
3775        let mut agent = admin_for(&addr, &[&a, &b]);
3776        agent
3777            .apply_host_snapshots(vec![
3778                (host_key(&a), procs_at(&addr, &["stale", "kept"])),
3779                (host_key(&b), procs_at(&addr, &["other"])),
3780            ])
3781            .unwrap();
3782
3783        agent
3784            .apply_host_snapshots(vec![(host_key(&a), procs_at(&addr, &["kept", "new"]))])
3785            .unwrap();
3786
3787        let owner = |name| {
3788            agent
3789                .proc_owners
3790                .get(&test_proc_id_with_addr(addr.clone(), name))
3791        };
3792        assert_eq!(owner("stale"), None);
3793        assert_eq!(owner("kept"), Some(&host_key(&a)));
3794        assert_eq!(owner("new"), Some(&host_key(&a)));
3795        assert_eq!(owner("other"), Some(&host_key(&b)));
3796    }
3797
3798    #[test]
3799    fn test_host_snapshot_claiming_another_hosts_proc_changes_nothing() {
3800        let (addr, a, b) = hosts_at_one_address();
3801        let mut agent = admin_for(&addr, &[&a, &b]);
3802        agent
3803            .apply_host_snapshots(vec![(host_key(&a), procs_at(&addr, &["p"]))])
3804            .unwrap();
3805        let before = agent.proc_owners.clone();
3806
3807        let err = agent
3808            .apply_host_snapshots(vec![(host_key(&b), procs_at(&addr, &["p", "q"]))])
3809            .unwrap_err();
3810
3811        assert!(err.to_string().contains("claimed by"), "{err}");
3812        assert_eq!(agent.proc_owners, before);
3813    }
3814
3815    #[test]
3816    fn test_host_snapshots_claiming_one_proc_change_nothing() {
3817        let (addr, a, b) = hosts_at_one_address();
3818        let mut agent = admin_for(&addr, &[&a, &b]);
3819
3820        let err = agent
3821            .apply_host_snapshots(vec![
3822                (host_key(&a), procs_at(&addr, &["p"])),
3823                (host_key(&b), procs_at(&addr, &["p"])),
3824            ])
3825            .unwrap_err();
3826
3827        assert!(err.to_string().contains("claimed by"), "{err}");
3828        assert!(agent.proc_owners.is_empty());
3829    }
3830
3831    #[test]
3832    fn test_snapshot_from_unconfigured_host_changes_nothing() {
3833        let (addr, a, b) = hosts_at_one_address();
3834        let mut agent = admin_for(&addr, &[&a]);
3835        agent
3836            .apply_host_snapshots(vec![(host_key(&a), procs_at(&addr, &["p"]))])
3837            .unwrap();
3838        let before = agent.proc_owners.clone();
3839
3840        let err = agent
3841            .apply_host_snapshots(vec![(host_key(&b), procs_at(&addr, &["q"]))])
3842            .unwrap_err();
3843
3844        assert!(err.to_string().contains("not configured"), "{err}");
3845        assert_eq!(agent.proc_owners, before);
3846    }
3847
3848    #[test]
3849    fn test_registration_order_does_not_change_owner() {
3850        let (addr, a, b) = hosts_at_one_address();
3851        for order in [[&a, &b], [&b, &a]] {
3852            let mut agent = admin_for(&addr, &order);
3853            agent
3854                .apply_host_snapshots(vec![
3855                    (host_key(&b), HashSet::new()),
3856                    (host_key(&a), procs_at(&addr, &["p"])),
3857                ])
3858                .unwrap();
3859            assert_eq!(
3860                agent
3861                    .proc_owners
3862                    .get(&test_proc_id_with_addr(addr.clone(), "p")),
3863                Some(&host_key(&a))
3864            );
3865        }
3866    }
3867
3868    /// A stand-in `HostAgent` for ownership tests. It publishes the
3869    /// attrs that `HostAgent::publish_introspect_properties` publishes,
3870    /// with a child list the test sets.
3871    #[derive(Debug)]
3872    #[hyperactor::export(handlers = [PublishProcs])]
3873    struct StandInHost;
3874    impl Actor for StandInHost {}
3875
3876    /// Replace a stand-in host's published child list, then acknowledge.
3877    #[derive(Debug, Serialize, Deserialize, Named)]
3878    struct PublishProcs(Vec<ProcAddr>, OncePortRef<()>);
3879    wirevalue::register_type!(PublishProcs);
3880
3881    #[async_trait]
3882    impl Handler<PublishProcs> for StandInHost {
3883        async fn handle(
3884            &mut self,
3885            cx: &Context<Self>,
3886            PublishProcs(procs, ack): PublishProcs,
3887        ) -> Result<(), anyhow::Error> {
3888            let mut attrs = hyperactor_config::Attrs::new();
3889            attrs.set(crate::introspect::NODE_TYPE, "host".to_string());
3890            attrs.set(crate::introspect::ADDR, cx.self_addr().addr().to_string());
3891            attrs.set(crate::introspect::NUM_PROCS, procs.len());
3892            attrs.set(
3893                hyperactor::introspect::CHILDREN,
3894                procs
3895                    .into_iter()
3896                    .map(hyperactor::introspect::IntrospectRef::Proc)
3897                    .collect(),
3898            );
3899            attrs.set(crate::introspect::SYSTEM_CHILDREN, Vec::new());
3900            cx.publish_attrs(attrs);
3901            ack.post(cx, ());
3902            Ok(())
3903        }
3904    }
3905
3906    /// Stand-in hosts `a` and `b` on a real proc that runs a
3907    /// `ProcAgent`, so the stand-ins and the proc share one channel
3908    /// address. The stand-ins have no `QueryChild` handler, so
3909    /// resolving the proc falls back to its `ProcAgent` and succeeds,
3910    /// and the returned `parent` shows which host the admin chose.
3911    struct SharedAddress {
3912        proc: hyperactor::Proc,
3913        client: hyperactor::Client,
3914        a: ActorRef<HostAgent>,
3915        b: ActorRef<HostAgent>,
3916    }
3917
3918    impl SharedAddress {
3919        async fn new() -> Self {
3920            use hyperactor::actor::ActorStatus;
3921            use hyperactor::channel::ChannelTransport;
3922
3923            let proc = hyperactor::Proc::direct(ChannelTransport::Unix.any(), "shared".to_string())
3924                .unwrap();
3925            let agent = crate::proc_agent::ProcAgent::boot_v1(proc.clone(), None).unwrap();
3926            tokio::time::timeout(
3927                Duration::from_secs(10),
3928                agent.status().wait_for(|s| matches!(s, ActorStatus::Idle)),
3929            )
3930            .await
3931            .expect("proc agent did not become idle")
3932            .unwrap();
3933            let client = proc.client("client");
3934            let a = Self::stand_in(&proc, "host_a");
3935            let b = Self::stand_in(&proc, "host_b");
3936            Self { proc, client, a, b }
3937        }
3938
3939        fn stand_in(proc: &hyperactor::Proc, label: &str) -> ActorRef<HostAgent> {
3940            ActorRef::attest(
3941                proc.spawn_with_label(label, StandInHost)
3942                    .bind::<StandInHost>()
3943                    .actor_addr()
3944                    .clone(),
3945            )
3946        }
3947
3948        // A configured candidate that never answers.
3949        fn silent(&self) -> ActorRef<HostAgent> {
3950            ActorRef::attest(self.proc.proc_addr().actor_addr("silent_host"))
3951        }
3952
3953        async fn publish(&self, host: &ActorRef<HostAgent>, procs: &[&hyperactor::Proc]) {
3954            let (ack, done) = open_once_port::<()>(&self.client);
3955            ActorRef::<StandInHost>::attest(host.actor_addr().clone()).post(
3956                &self.client,
3957                PublishProcs(
3958                    procs.iter().map(|proc| proc.proc_addr()).collect(),
3959                    ack.bind(),
3960                ),
3961            );
3962            tokio::time::timeout(Duration::from_secs(10), done.recv())
3963                .await
3964                .expect("stand-in host did not acknowledge its republish")
3965                .unwrap();
3966        }
3967
3968        async fn admin(&self, hosts: &[&ActorRef<HostAgent>]) -> Admin {
3969            use hyperactor::channel::ChannelTransport;
3970
3971            let addr = self.proc.proc_addr().addr().to_string();
3972            let proc = hyperactor::Proc::direct(ChannelTransport::Unix.any(), "admin".to_string())
3973                .unwrap();
3974            let supervision =
3975                hyperactor::testing::proc_supervison::ProcSupervisionCoordinator::set(&proc)
3976                    .await
3977                    .unwrap();
3978            let admin_ref = proc
3979                .spawn_with_label(
3980                    MESH_ADMIN_ACTOR_NAME,
3981                    MeshAdminAgent::new(
3982                        hosts
3983                            .iter()
3984                            .map(|host| (addr.clone(), (*host).clone()))
3985                            .collect(),
3986                        None,
3987                        Some("[::]:0".parse().unwrap()),
3988                        None,
3989                    ),
3990                )
3991                .bind();
3992            Admin {
3993                admin_ref,
3994                _proc: proc,
3995                _supervision: supervision,
3996            }
3997        }
3998
3999        async fn resolve(
4000            &self,
4001            admin: &Admin,
4002            node: crate::introspect::NodeRef,
4003        ) -> Result<NodePayload, String> {
4004            tokio::time::timeout(
4005                Duration::from_secs(10),
4006                admin.admin_ref.resolve(&self.client, node.to_string()),
4007            )
4008            .await
4009            .expect("admin did not answer the resolve")
4010            .unwrap()
4011            .0
4012        }
4013
4014        async fn resolve_proc(&self, admin: &Admin) -> Result<NodePayload, String> {
4015            self.resolve(
4016                admin,
4017                crate::introspect::NodeRef::Proc(self.proc.proc_addr()),
4018            )
4019            .await
4020        }
4021    }
4022
4023    struct Admin {
4024        admin_ref: ActorRef<MeshAdminAgent>,
4025        _proc: hyperactor::Proc,
4026        _supervision: (
4027            hyperactor::testing::proc_supervison::ReportedEvent,
4028            ActorHandle<hyperactor::testing::proc_supervison::ProcSupervisionCoordinator>,
4029        ),
4030    }
4031
4032    fn host_ref(host: &ActorRef<HostAgent>) -> Option<crate::introspect::NodeRef> {
4033        Some(crate::introspect::NodeRef::Host(host.actor_addr().clone()))
4034    }
4035
4036    // Resolving a host records the procs it lists, so a later proc
4037    // resolve keeps that owner even after the snapshots change.
4038    #[tokio::test]
4039    async fn test_host_resolve_records_proc_owner() {
4040        let shared = SharedAddress::new().await;
4041        let admin = shared.admin(&[&shared.a, &shared.b]).await;
4042        shared.publish(&shared.a, &[&shared.proc]).await;
4043        shared.publish(&shared.b, &[]).await;
4044
4045        shared
4046            .resolve(
4047                &admin,
4048                crate::introspect::NodeRef::Host(shared.a.actor_addr().clone()),
4049            )
4050            .await
4051            .unwrap();
4052        shared.publish(&shared.a, &[]).await;
4053        shared.publish(&shared.b, &[&shared.proc]).await;
4054
4055        let proc = shared.resolve_proc(&admin).await.unwrap();
4056        assert_eq!(proc.parent, host_ref(&shared.a));
4057    }
4058
4059    // A proc at an address shared by two hosts belongs to the host that
4060    // lists it, in either registration order, and the answer is kept.
4061    #[tokio::test]
4062    async fn test_shared_address_proc_resolve_asks_the_candidates() {
4063        let shared = SharedAddress::new().await;
4064        for order in [[&shared.a, &shared.b], [&shared.b, &shared.a]] {
4065            let admin = shared.admin(&order).await;
4066            shared.publish(&shared.a, &[&shared.proc]).await;
4067            shared.publish(&shared.b, &[]).await;
4068
4069            let proc = shared.resolve_proc(&admin).await.unwrap();
4070            assert_eq!(proc.parent, host_ref(&shared.a));
4071
4072            shared.publish(&shared.a, &[]).await;
4073            shared.publish(&shared.b, &[&shared.proc]).await;
4074            let proc = shared.resolve_proc(&admin).await.unwrap();
4075            assert_eq!(proc.parent, host_ref(&shared.a));
4076        }
4077    }
4078
4079    // Resolving a host the admin was not configured with does not
4080    // record or contest ownership.
4081    #[tokio::test]
4082    async fn test_unconfigured_host_resolve_is_read_only() {
4083        let shared = SharedAddress::new().await;
4084        let other = SharedAddress::stand_in(&shared.proc, "host_c");
4085        let admin = shared.admin(&[&shared.a, &shared.b]).await;
4086        shared.publish(&shared.a, &[&shared.proc]).await;
4087        shared.publish(&shared.b, &[]).await;
4088        shared.publish(&other, &[&shared.proc]).await;
4089
4090        let proc = shared.resolve_proc(&admin).await.unwrap();
4091        assert_eq!(proc.parent, host_ref(&shared.a));
4092        shared
4093            .resolve(
4094                &admin,
4095                crate::introspect::NodeRef::Host(other.actor_addr().clone()),
4096            )
4097            .await
4098            .unwrap();
4099        let proc = shared.resolve_proc(&admin).await.unwrap();
4100        assert_eq!(proc.parent, host_ref(&shared.a));
4101    }
4102
4103    // A sole host at an address owns the procs there without being
4104    // asked.
4105    #[tokio::test]
4106    async fn test_sole_host_owns_procs_at_its_address() {
4107        let shared = SharedAddress::new().await;
4108        let admin = shared.admin(&[&shared.a]).await;
4109        shared.publish(&shared.a, &[]).await;
4110
4111        let proc = shared.resolve_proc(&admin).await.unwrap();
4112        assert_eq!(proc.parent, host_ref(&shared.a));
4113    }
4114
4115    #[tokio::test]
4116    async fn test_listing_host_wins_over_silent_candidate() {
4117        let config = hyperactor_config::global::lock();
4118        let _timeout = config.override_key(
4119            crate::config::MESH_ADMIN_SINGLE_HOST_TIMEOUT,
4120            Duration::from_secs(1),
4121        );
4122        let shared = SharedAddress::new().await;
4123        let silent = shared.silent();
4124        let admin = shared.admin(&[&silent, &shared.a]).await;
4125        shared.publish(&shared.a, &[&shared.proc]).await;
4126
4127        let proc = shared.resolve_proc(&admin).await.unwrap();
4128        assert_eq!(proc.parent, host_ref(&shared.a));
4129    }
4130
4131    #[tokio::test]
4132    async fn test_proc_no_candidate_lists_has_no_host() {
4133        let shared = SharedAddress::new().await;
4134        let admin = shared.admin(&[&shared.a, &shared.b]).await;
4135        shared.publish(&shared.a, &[]).await;
4136        shared.publish(&shared.b, &[]).await;
4137
4138        let err = shared.resolve_proc(&admin).await.unwrap_err();
4139        assert!(err.contains("no host found"), "{err}");
4140    }
4141
4142    // If a candidate does not answer and no answering host lists the
4143    // proc, its owner is unknown; the admin does not guess.
4144    #[tokio::test]
4145    async fn test_silent_candidate_leaves_owner_unknown() {
4146        let config = hyperactor_config::global::lock();
4147        let _timeout = config.override_key(
4148            crate::config::MESH_ADMIN_SINGLE_HOST_TIMEOUT,
4149            Duration::from_secs(1),
4150        );
4151        let shared = SharedAddress::new().await;
4152        let silent = shared.silent();
4153        let admin = shared.admin(&[&silent, &shared.a]).await;
4154        shared.publish(&shared.a, &[]).await;
4155
4156        let err = shared.resolve_proc(&admin).await.unwrap_err();
4157        assert!(err.contains("cannot tell which host"), "{err}");
4158    }
4159
4160    #[tokio::test]
4161    async fn test_two_hosts_listing_a_proc_is_an_error() {
4162        let shared = SharedAddress::new().await;
4163        let admin = shared.admin(&[&shared.a, &shared.b]).await;
4164        shared.publish(&shared.a, &[&shared.proc]).await;
4165        shared.publish(&shared.b, &[&shared.proc]).await;
4166
4167        let err = shared.resolve_proc(&admin).await.unwrap_err();
4168        assert!(err.contains("claimed by"), "{err}");
4169    }
4170
4171    // End-to-end smoke test for MeshAdminAgent::resolve that walks
4172    // the reference tree: root → host → system proc → host-agent
4173    // cross-reference. Verifies the reverse index routes the
4174    // HostAgent ActorAddr to NodeProperties::Host (not Actor),
4175    // preventing the TUI's cycle detection from dropping that node.
4176    #[tokio::test]
4177    async fn test_resolve_reference_tree_walk() {
4178        use hyperactor::Proc;
4179        use hyperactor::channel::ChannelTransport;
4180
4181        use crate::host::Host;
4182        use crate::host::LocalProcManager;
4183        use crate::host_mesh::host_agent::ProcManagerSpawnFn;
4184        use crate::proc_agent::ProcAgent;
4185
4186        // -- 1. Stand up a local in-process Host with a HostAgent --
4187        // Use Unix transport for all procs — Local transport does not
4188        // support cross-proc message routing.
4189        let spawn: ProcManagerSpawnFn =
4190            Box::new(|proc| Box::pin(std::future::ready(ProcAgent::boot_v1(proc, None))));
4191        let manager: LocalProcManager<ProcManagerSpawnFn> = LocalProcManager::new(spawn);
4192        let host: Host<LocalProcManager<ProcManagerSpawnFn>> =
4193            Host::new(manager, ChannelTransport::Unix.any())
4194                .await
4195                .unwrap();
4196        let host_addr = host.addr().clone();
4197        let system_proc = host.system_proc().clone();
4198        let host_agent_handle = system_proc.spawn_with_label(
4199            crate::host_mesh::host_agent::HOST_MESH_AGENT_ACTOR_NAME,
4200            HostAgent::new_local(host),
4201        );
4202        HostAgent::wait_initialized(&host_agent_handle)
4203            .await
4204            .unwrap();
4205        let host_agent_ref: ActorRef<HostAgent> = host_agent_handle.bind();
4206        let host_addr_str = host_addr.to_string();
4207
4208        // -- 2. Spawn MeshAdminAgent on a dedicated test proc --
4209        // NOTE: This does not conform to SA-5 (caller-local placement).
4210        // Production uses host_mesh::spawn_admin(). This is a white-box
4211        // test of admin behavior, not placement.
4212        let admin_proc = Proc::direct(ChannelTransport::Unix.any(), "admin".to_string()).unwrap();
4213        // The admin proc has no supervision coordinator by default.
4214        // Without one, actor teardown triggers std::process::exit(1).
4215        use hyperactor::testing::proc_supervison::ProcSupervisionCoordinator;
4216        let _supervision = ProcSupervisionCoordinator::set(&admin_proc).await.unwrap();
4217        let admin_handle = admin_proc.spawn_with_label(
4218            MESH_ADMIN_ACTOR_NAME,
4219            MeshAdminAgent::new(
4220                vec![(host_addr_str.clone(), host_agent_ref.clone())],
4221                None,
4222                Some("[::]:0".parse().unwrap()),
4223                None,
4224            ),
4225        );
4226        let admin_ref: ActorRef<MeshAdminAgent> = admin_handle.bind();
4227
4228        // -- 3. Create a bare client instance for sending messages --
4229        // Only a mailbox is needed for reply ports — no actor message
4230        // loop required.
4231        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
4232        let client = client_proc.client("client");
4233
4234        // -- 4. Resolve "root" --
4235        let root_resp = admin_ref
4236            .resolve(&client, "root".to_string())
4237            .await
4238            .unwrap();
4239        let root = root_resp.0.unwrap();
4240        assert_eq!(root.identity, crate::introspect::NodeRef::Root);
4241        assert!(matches!(
4242            root.properties,
4243            NodeProperties::Root { num_hosts: 1, .. }
4244        ));
4245        assert_eq!(root.parent, None);
4246        assert_eq!(root.children.len(), 1); // host only (admin proc no longer standalone)
4247
4248        // -- 5. Resolve the host child --
4249        let expected_host_ref =
4250            crate::introspect::NodeRef::Host(host_agent_ref.actor_addr().clone());
4251        let host_child_ref = root
4252            .children
4253            .iter()
4254            .find(|c| **c == expected_host_ref)
4255            .expect("root children should contain the host agent (as Host ref)");
4256        let host_ref_string = host_child_ref.to_string();
4257        let host_resp = admin_ref.resolve(&client, host_ref_string).await.unwrap();
4258        let host_node = host_resp.0.unwrap();
4259        assert_eq!(host_node.identity, expected_host_ref);
4260        assert!(
4261            matches!(host_node.properties, NodeProperties::Host { .. }),
4262            "expected Host properties, got {:?}",
4263            host_node.properties
4264        );
4265        assert_eq!(host_node.parent, Some(crate::introspect::NodeRef::Root));
4266        assert!(
4267            !host_node.children.is_empty(),
4268            "host should have at least one proc child"
4269        );
4270        // LC-2: host system_children is always empty.
4271        match &host_node.properties {
4272            NodeProperties::Host {
4273                system_children, ..
4274            } => {
4275                assert!(
4276                    system_children.is_empty(),
4277                    "LC-2: host system_children must be empty"
4278                );
4279            }
4280            other => panic!("expected Host, got {:?}", other),
4281        }
4282
4283        // -- 6. Resolve a system proc child --
4284        let proc_ref = &host_node.children[0];
4285        let proc_ref_str = proc_ref.to_string();
4286        let proc_resp = admin_ref.resolve(&client, proc_ref_str).await.unwrap();
4287        let proc_node = proc_resp.0.unwrap();
4288        assert!(
4289            matches!(proc_node.properties, NodeProperties::Proc { .. }),
4290            "expected Proc properties, got {:?}",
4291            proc_node.properties
4292        );
4293        assert_eq!(proc_node.parent, Some(expected_host_ref.clone()));
4294        // The system proc should have at least the "host_agent" actor.
4295        assert!(
4296            !proc_node.children.is_empty(),
4297            "proc should have at least one actor child"
4298        );
4299
4300        // -- 7. Cross-reference: system proc child is the host agent --
4301        //
4302        // The service proc's actor (agent[0]) IS the HostAgent, so
4303        // it appears both as a host node (from root, via NodeRef::Host)
4304        // and as an actor (from a proc's children list, via NodeRef::Actor).
4305        // NodeRef::Host in root children makes resolution unambiguous:
4306        // host refs get Entity view, plain actor refs get Actor view.
4307
4308        // The system proc must list the host agent among its children.
4309        let host_agent_node_ref =
4310            crate::introspect::NodeRef::Actor(host_agent_ref.actor_addr().clone());
4311        assert!(
4312            proc_node.children.contains(&host_agent_node_ref),
4313            "system proc children {:?} should contain the host agent {:?}",
4314            proc_node.children,
4315            host_agent_node_ref
4316        );
4317
4318        // Resolve that child reference as a plain actor (no host: prefix).
4319        let xref_resp = admin_ref
4320            .resolve(&client, host_agent_ref.actor_addr().to_string())
4321            .await
4322            .unwrap();
4323        let xref_node = xref_resp.0.unwrap();
4324
4325        // When resolved as a plain actor reference, it must return
4326        // Actor properties (not Host), because it has no host: prefix.
4327        assert!(
4328            matches!(xref_node.properties, NodeProperties::Actor { .. }),
4329            "host agent child resolved as plain actor should be Actor, got {:?}",
4330            xref_node.properties
4331        );
4332    }
4333
4334    // Verifies MeshAdminAgent::resolve returns NodeProperties::Proc
4335    // for all proc children. Spawns a user proc via
4336    // CreateOrUpdate<ProcSpec>, resolves all host proc-children, and
4337    // asserts every proc returns Proc properties.
4338    #[tokio::test]
4339    async fn test_proc_properties_for_all_procs() {
4340        use std::time::Duration;
4341
4342        use hyperactor::Proc;
4343        use hyperactor::channel::ChannelTransport;
4344        use hyperactor::id::Label;
4345
4346        use crate::host::Host;
4347        use crate::host::LocalProcManager;
4348        use crate::host_mesh::host_agent::ProcManagerSpawnFn;
4349        use crate::proc_agent::ProcAgent;
4350        use crate::resource;
4351        use crate::resource::ProcSpec;
4352        use crate::resource::Rank;
4353
4354        // Stand up a local in-process Host with a HostAgent.
4355        let spawn: ProcManagerSpawnFn =
4356            Box::new(|proc| Box::pin(std::future::ready(ProcAgent::boot_v1(proc, None))));
4357        let manager: LocalProcManager<ProcManagerSpawnFn> = LocalProcManager::new(spawn);
4358        let host: Host<LocalProcManager<ProcManagerSpawnFn>> =
4359            Host::new(manager, ChannelTransport::Unix.any())
4360                .await
4361                .unwrap();
4362        let host_addr = host.addr().clone();
4363        let system_proc_id: ProcAddr = host.system_proc().proc_addr().clone();
4364        let local_proc_id: ProcAddr = host.local_proc().proc_addr().clone();
4365        let system_proc = host.system_proc().clone();
4366        let host_agent_handle = system_proc.spawn_with_label(
4367            crate::host_mesh::host_agent::HOST_MESH_AGENT_ACTOR_NAME,
4368            HostAgent::new_local(host),
4369        );
4370        HostAgent::wait_initialized(&host_agent_handle)
4371            .await
4372            .unwrap();
4373        let host_agent_ref: ActorRef<HostAgent> = host_agent_handle.bind();
4374        let host_addr_str = host_addr.to_string();
4375
4376        // Spawn MeshAdminAgent on a dedicated test proc.
4377        // NOTE: Does not conform to SA-5 (caller-local placement).
4378        // Production uses host_mesh::spawn_admin(). White-box test setup.
4379        let admin_proc = Proc::direct(ChannelTransport::Unix.any(), "admin".to_string()).unwrap();
4380        use hyperactor::testing::proc_supervison::ProcSupervisionCoordinator;
4381        let _supervision = ProcSupervisionCoordinator::set(&admin_proc).await.unwrap();
4382        let admin_handle = admin_proc.spawn_with_label(
4383            MESH_ADMIN_ACTOR_NAME,
4384            MeshAdminAgent::new(
4385                vec![(host_addr_str.clone(), host_agent_ref.clone())],
4386                None,
4387                Some("[::]:0".parse().unwrap()),
4388                None,
4389            ),
4390        );
4391        let admin_ref: ActorRef<MeshAdminAgent> = admin_handle.bind();
4392
4393        // Create a bare client instance for sending messages.
4394        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
4395        let client = client_proc.client("client");
4396
4397        // Spawn a user proc via CreateOrUpdate<ProcSpec>.
4398        let user_proc_name = ResourceId::instance(Label::new("user-proc").unwrap());
4399        host_agent_ref.post(
4400            &client,
4401            resource::CreateOrUpdate {
4402                id: user_proc_name.clone(),
4403                rank: Rank::new(0),
4404                spec: ProcSpec::default(),
4405            },
4406        );
4407
4408        // Wait for the user proc to boot.
4409        tokio::time::sleep(Duration::from_secs(2)).await;
4410
4411        // Resolve the host to get its children (system + user procs).
4412        let host_ref_string =
4413            crate::introspect::NodeRef::Host(host_agent_ref.actor_addr().clone()).to_string();
4414        let host_resp = admin_ref.resolve(&client, host_ref_string).await.unwrap();
4415        let host_node = host_resp.0.unwrap();
4416
4417        // The host should have at least 3 children: system proc,
4418        // local proc, and our user proc.
4419        assert!(
4420            host_node.children.len() >= 3,
4421            "expected at least 3 proc children (2 system + 1 user), got {}",
4422            host_node.children.len()
4423        );
4424
4425        // Resolve each proc child and verify it has Proc properties.
4426        let mut found_system = false;
4427        let mut found_user = false;
4428        for child_ref in &host_node.children {
4429            let resp = admin_ref
4430                .resolve(&client, child_ref.to_string())
4431                .await
4432                .unwrap();
4433            let node = resp.0.unwrap();
4434            if let NodeProperties::Proc { .. } = &node.properties {
4435                if matches!(
4436                    child_ref,
4437                    crate::introspect::NodeRef::Proc(proc_id)
4438                        if *proc_id != system_proc_id && *proc_id != local_proc_id
4439                ) {
4440                    found_user = true;
4441                } else {
4442                    found_system = true;
4443                }
4444                // Properties derived from attrs — verified by derive_properties tests.
4445            } else {
4446                // Host agent cross-reference — skip.
4447            }
4448        }
4449        assert!(
4450            found_system,
4451            "should have resolved at least one system proc"
4452        );
4453        assert!(found_user, "should have resolved the user proc");
4454    }
4455
4456    // Verifies that build_root_payload lists only the host as a
4457    // child. The root client is visible under its host's local proc,
4458    // not at root level.
4459    #[test]
4460    fn test_build_root_payload_with_root_client() {
4461        let addr1: SocketAddr = "127.0.0.1:9001".parse().unwrap();
4462        let proc1 = ResourceId::proc_addr_from_name(ChannelAddr::Tcp(addr1), "host1");
4463        let actor_id1 = hyperactor::ActorAddr::root(proc1, Label::new("mesh_agent").unwrap());
4464        let ref1: ActorRef<HostAgent> = ActorRef::attest(actor_id1.clone());
4465
4466        let client_proc_id = ResourceId::proc_addr_from_name(ChannelAddr::Tcp(addr1), "local");
4467        let client_actor_id = client_proc_id.actor_addr("client");
4468
4469        let agent = MeshAdminAgent::new(
4470            vec![("host_a".to_string(), ref1)],
4471            Some(client_actor_id.clone()),
4472            None,
4473            None,
4474        );
4475
4476        let payload = agent.build_root_payload();
4477        assert!(matches!(
4478            payload.properties,
4479            NodeProperties::Root { num_hosts: 1, .. }
4480        ));
4481        // Only the host; root client is under host → local proc.
4482        assert_eq!(payload.children.len(), 1);
4483        assert!(
4484            payload
4485                .children
4486                .contains(&crate::introspect::NodeRef::Host(actor_id1.clone()))
4487        );
4488    }
4489
4490    // Verifies that the root client actor is visible through the
4491    // host → local proc → actor path, not as a standalone child of
4492    // root.
4493    #[tokio::test]
4494    async fn test_resolve_root_client_actor() {
4495        use hyperactor::channel::ChannelTransport;
4496
4497        use crate::host::Host;
4498        use crate::host::LocalProcManager;
4499        use crate::host_mesh::host_agent::ProcManagerSpawnFn;
4500        use crate::proc_agent::ProcAgent;
4501
4502        // Stand up a local in-process Host with a HostAgent.
4503        let spawn: ProcManagerSpawnFn =
4504            Box::new(|proc| Box::pin(std::future::ready(ProcAgent::boot_v1(proc, None))));
4505        let manager: LocalProcManager<ProcManagerSpawnFn> = LocalProcManager::new(spawn);
4506        let host: Host<LocalProcManager<ProcManagerSpawnFn>> =
4507            Host::new(manager, ChannelTransport::Unix.any())
4508                .await
4509                .unwrap();
4510        let host_addr = host.addr().clone();
4511        let system_proc = host.system_proc().clone();
4512
4513        // Spawn the root client on the host's local proc (before
4514        // moving the host into HostAgent).
4515        let local_proc = host.local_proc();
4516        let local_proc_id = local_proc.proc_addr().clone();
4517        let root_client_handle = local_proc.spawn_with_label("client", TestIntrospectableActor);
4518        let root_client_ref: ActorRef<TestIntrospectableActor> = root_client_handle.bind();
4519        let root_client_actor_id = root_client_ref.actor_addr().clone();
4520
4521        let host_agent_handle = system_proc.spawn_with_label(
4522            crate::host_mesh::host_agent::HOST_MESH_AGENT_ACTOR_NAME,
4523            HostAgent::new_local(host),
4524        );
4525        HostAgent::wait_initialized(&host_agent_handle)
4526            .await
4527            .unwrap();
4528        let host_agent_ref: ActorRef<HostAgent> = host_agent_handle.bind();
4529        let host_addr_str = host_addr.to_string();
4530
4531        // Spawn MeshAdminAgent on a dedicated test proc with the root
4532        // client ActorAddr. NOTE: Does not conform to SA-5 (caller-local
4533        // placement). Production uses host_mesh::spawn_admin().
4534        // White-box test of root-client visibility, not placement.
4535        let admin_proc =
4536            hyperactor::Proc::direct(ChannelTransport::Unix.any(), "admin".to_string()).unwrap();
4537        use hyperactor::testing::proc_supervison::ProcSupervisionCoordinator;
4538        let _supervision = ProcSupervisionCoordinator::set(&admin_proc).await.unwrap();
4539        let admin_handle = admin_proc.spawn_with_label(
4540            MESH_ADMIN_ACTOR_NAME,
4541            MeshAdminAgent::new(
4542                vec![(host_addr_str.clone(), host_agent_ref.clone())],
4543                Some(root_client_actor_id.clone()),
4544                Some("[::]:0".parse().unwrap()),
4545                None,
4546            ),
4547        );
4548        let admin_ref: ActorRef<MeshAdminAgent> = admin_handle.bind();
4549
4550        // Client for sending messages.
4551        let client_proc =
4552            hyperactor::Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
4553        let client = client_proc.client("client");
4554
4555        // Resolve "root" — should contain only the host.
4556        let root_resp = admin_ref
4557            .resolve(&client, "root".to_string())
4558            .await
4559            .unwrap();
4560        let root = root_resp.0.unwrap();
4561        let host_node_ref = crate::introspect::NodeRef::Host(host_agent_ref.actor_addr().clone());
4562        assert!(
4563            root.children.contains(&host_node_ref),
4564            "root children {:?} should contain host {:?}",
4565            root.children,
4566            host_node_ref
4567        );
4568
4569        // Resolve the host — should list the local proc in children.
4570        let host_resp = admin_ref
4571            .resolve(&client, host_node_ref.to_string())
4572            .await
4573            .unwrap();
4574        let host_node = host_resp.0.unwrap();
4575        let local_proc_node_ref = crate::introspect::NodeRef::Proc(local_proc_id.clone());
4576        assert!(
4577            host_node.children.contains(&local_proc_node_ref),
4578            "host children {:?} should contain local proc {:?}",
4579            host_node.children,
4580            local_proc_node_ref
4581        );
4582
4583        // Resolve the local proc — should contain the root client actor.
4584        let proc_resp = admin_ref
4585            .resolve(&client, local_proc_id.to_string())
4586            .await
4587            .unwrap();
4588        let proc_node = proc_resp.0.unwrap();
4589        assert!(
4590            matches!(proc_node.properties, NodeProperties::Proc { .. }),
4591            "expected Proc properties, got {:?}",
4592            proc_node.properties
4593        );
4594        let root_client_node_ref = crate::introspect::NodeRef::Actor(root_client_actor_id.clone());
4595        assert!(
4596            proc_node.children.contains(&root_client_node_ref),
4597            "local proc children {:?} should contain root client actor {:?}",
4598            proc_node.children,
4599            root_client_node_ref
4600        );
4601
4602        // Resolve the root client actor — parent should be the local proc.
4603        let client_resp = admin_ref
4604            .resolve(&client, root_client_actor_id.to_string())
4605            .await
4606            .unwrap();
4607        let client_node = client_resp.0.unwrap();
4608        assert!(
4609            matches!(client_node.properties, NodeProperties::Actor { .. }),
4610            "expected Actor properties, got {:?}",
4611            client_node.properties
4612        );
4613        assert_eq!(
4614            client_node.parent,
4615            Some(local_proc_node_ref),
4616            "root client parent should be the local proc"
4617        );
4618    }
4619
4620    // Verifies that the SKILL.md template contains the canonical
4621    // strings that agents and tests rely on. Prevents silent drift or
4622    // accidental removal.
4623    #[test]
4624    fn test_skill_md_contains_canonical_strings() {
4625        let template = SKILL_MD_TEMPLATE;
4626        assert!(
4627            template.contains("GET {base}/v1/root"),
4628            "SKILL.md must document the root endpoint"
4629        );
4630        assert!(
4631            template.contains("GET {base}/v1/{reference}"),
4632            "SKILL.md must document the reference endpoint"
4633        );
4634        assert!(
4635            template.contains("NodePayload"),
4636            "SKILL.md must mention the NodePayload response type"
4637        );
4638        assert!(
4639            template.contains("GET {base}/SKILL.md"),
4640            "SKILL.md must document itself"
4641        );
4642        assert!(
4643            template.contains("{base}"),
4644            "SKILL.md must use {{base}} placeholder for interpolation"
4645        );
4646    }
4647
4648    // Verifies the navigation identity invariant (see module docs):
4649    //
4650    // 1. payload.identity == reference_string used to resolve it.
4651    // 2. For each child reference C of a resolved node P,
4652    //    resolve(C).parent == P.identity.
4653    //
4654    // Walks the entire tree starting from root, checking both
4655    // properties at every reachable node.
4656    #[tokio::test]
4657    async fn test_navigation_identity_invariant() {
4658        use hyperactor::Proc;
4659        use hyperactor::channel::ChannelTransport;
4660
4661        use crate::host::Host;
4662        use crate::host::LocalProcManager;
4663        use crate::host_mesh::host_agent::ProcManagerSpawnFn;
4664        use crate::proc_agent::ProcAgent;
4665
4666        // Stand up a local host with a HostAgent.
4667        let spawn: ProcManagerSpawnFn =
4668            Box::new(|proc| Box::pin(std::future::ready(ProcAgent::boot_v1(proc, None))));
4669        let manager: LocalProcManager<ProcManagerSpawnFn> = LocalProcManager::new(spawn);
4670        let host: Host<LocalProcManager<ProcManagerSpawnFn>> =
4671            Host::new(manager, ChannelTransport::Unix.any())
4672                .await
4673                .unwrap();
4674        let host_addr = host.addr().clone();
4675        let system_proc = host.system_proc().clone();
4676        let host_agent_handle = system_proc.spawn_with_label(
4677            crate::host_mesh::host_agent::HOST_MESH_AGENT_ACTOR_NAME,
4678            HostAgent::new_local(host),
4679        );
4680        HostAgent::wait_initialized(&host_agent_handle)
4681            .await
4682            .unwrap();
4683        let host_agent_ref: ActorRef<HostAgent> = host_agent_handle.bind();
4684        let host_addr_str = host_addr.to_string();
4685
4686        // Spawn MeshAdminAgent on a dedicated test proc.
4687        // NOTE: Does not conform to SA-5 (caller-local placement).
4688        // Production uses host_mesh::spawn_admin(). White-box test setup.
4689        let admin_proc = Proc::direct(ChannelTransport::Unix.any(), "admin".to_string()).unwrap();
4690        use hyperactor::testing::proc_supervison::ProcSupervisionCoordinator;
4691        let _supervision = ProcSupervisionCoordinator::set(&admin_proc).await.unwrap();
4692        let admin_handle = admin_proc.spawn_with_label(
4693            MESH_ADMIN_ACTOR_NAME,
4694            MeshAdminAgent::new(
4695                vec![(host_addr_str, host_agent_ref)],
4696                None,
4697                Some("[::]:0".parse().unwrap()),
4698                None,
4699            ),
4700        );
4701        let admin_ref: ActorRef<MeshAdminAgent> = admin_handle.bind();
4702
4703        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
4704        let client = client_proc.client("client");
4705
4706        // Walk the tree breadth-first, checking the invariant at every node.
4707        // Each entry is (reference_string, expected_parent_identity).
4708        let mut queue: std::collections::VecDeque<(String, Option<crate::introspect::NodeRef>)> =
4709            std::collections::VecDeque::new();
4710        queue.push_back(("root".to_string(), None));
4711
4712        let mut visited = std::collections::HashSet::new();
4713        while let Some((ref_str, expected_parent)) = queue.pop_front() {
4714            if !visited.insert(ref_str.clone()) {
4715                continue;
4716            }
4717
4718            let resp = admin_ref.resolve(&client, ref_str.clone()).await.unwrap();
4719            let node = resp.0.unwrap();
4720
4721            // NI-1: identity display matches the reference used.
4722            assert_eq!(
4723                node.identity.to_string(),
4724                ref_str,
4725                "identity mismatch: resolved '{}' but payload.identity = '{}'",
4726                ref_str,
4727                node.identity
4728            );
4729
4730            // NI-2: parent matches the parent node's identity.
4731            assert_eq!(
4732                node.parent, expected_parent,
4733                "parent mismatch for '{}': expected {:?}, got {:?}",
4734                ref_str, expected_parent, node.parent
4735            );
4736
4737            // Enqueue children with this node's identity as their
4738            // expected parent.
4739            for child_ref in &node.children {
4740                let child_str = child_ref.to_string();
4741                if !visited.contains(&child_str) {
4742                    queue.push_back((child_str, Some(node.identity.clone())));
4743                }
4744            }
4745        }
4746
4747        // Sanity: we should have visited at least root, host, a
4748        // proc, and an actor.
4749        assert!(
4750            visited.len() >= 4,
4751            "expected at least 4 nodes in the tree, visited {}",
4752            visited.len()
4753        );
4754    }
4755
4756    // Exercises SP-1..SP-4 for host/proc payloads.
4757    #[tokio::test]
4758    async fn test_system_proc_identity() {
4759        use hyperactor::Proc;
4760        use hyperactor::channel::ChannelTransport;
4761
4762        use crate::host::Host;
4763        use crate::host::LocalProcManager;
4764        use crate::host_mesh::host_agent::ProcManagerSpawnFn;
4765        use crate::proc_agent::ProcAgent;
4766
4767        // -- 1. Stand up a local in-process Host with a HostAgent --
4768        let spawn: ProcManagerSpawnFn =
4769            Box::new(|proc| Box::pin(std::future::ready(ProcAgent::boot_v1(proc, None))));
4770        let manager: LocalProcManager<ProcManagerSpawnFn> = LocalProcManager::new(spawn);
4771        let host: Host<LocalProcManager<ProcManagerSpawnFn>> =
4772            Host::new(manager, ChannelTransport::Unix.any())
4773                .await
4774                .unwrap();
4775        let host_addr = host.addr().clone();
4776        let system_proc = host.system_proc().clone();
4777        let system_proc_id = system_proc.proc_addr().clone();
4778        let host_agent_handle = system_proc.spawn_with_label(
4779            crate::host_mesh::host_agent::HOST_MESH_AGENT_ACTOR_NAME,
4780            HostAgent::new_local(host),
4781        );
4782        HostAgent::wait_initialized(&host_agent_handle)
4783            .await
4784            .unwrap();
4785        let host_agent_ref: ActorRef<HostAgent> = host_agent_handle.bind();
4786        let host_addr_str = host_addr.to_string();
4787
4788        // -- 2. Spawn MeshAdminAgent on a dedicated test proc --
4789        // NOTE: This does not conform to SA-5 (caller-local placement).
4790        // Production uses host_mesh::spawn_admin(). This is a white-box
4791        // test of admin behavior, not placement.
4792        let admin_proc = Proc::direct(ChannelTransport::Unix.any(), "admin".to_string()).unwrap();
4793        use hyperactor::testing::proc_supervison::ProcSupervisionCoordinator;
4794        let _supervision = ProcSupervisionCoordinator::set(&admin_proc).await.unwrap();
4795        let admin_handle = admin_proc.spawn_with_label(
4796            MESH_ADMIN_ACTOR_NAME,
4797            MeshAdminAgent::new(
4798                vec![(host_addr_str.clone(), host_agent_ref.clone())],
4799                None,
4800                Some("[::]:0".parse().unwrap()),
4801                None,
4802            ),
4803        );
4804        let admin_ref: ActorRef<MeshAdminAgent> = admin_handle.bind();
4805
4806        // -- 3. Create a bare client instance for sending messages --
4807        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
4808        let client = client_proc.client("client");
4809
4810        // -- 4. Resolve the host to get its children --
4811        let host_ref_str =
4812            crate::introspect::NodeRef::Host(host_agent_ref.actor_addr().clone()).to_string();
4813        let host_resp = admin_ref
4814            .resolve(&client, host_ref_str.clone())
4815            .await
4816            .unwrap();
4817        let host_node = host_resp.0.unwrap();
4818        assert!(
4819            !host_node.children.is_empty(),
4820            "host should have at least one proc child"
4821        );
4822
4823        // -- 5. Find a system proc child via system_children --
4824        let system_children = match &host_node.properties {
4825            NodeProperties::Host {
4826                system_children, ..
4827            } => system_children.clone(),
4828            other => panic!("expected Host properties, got {:?}", other),
4829        };
4830        // Procs are never system — host system_children should be empty.
4831        assert!(
4832            system_children.is_empty(),
4833            "host system_children should be empty (procs are never system), got {:?}",
4834            system_children
4835        );
4836        // Verify host properties derived from attrs.
4837        assert!(
4838            matches!(&host_node.properties, NodeProperties::Host { .. }),
4839            "expected Host properties"
4840        );
4841
4842        // -- 6. Verify host children contain the system proc --
4843        let expected_system_ref = crate::introspect::NodeRef::Proc(system_proc_id.clone());
4844        assert!(
4845            host_node.children.contains(&expected_system_ref),
4846            "host children {:?} should contain the system proc ref {:?}",
4847            host_node.children,
4848            expected_system_ref
4849        );
4850
4851        // -- 7. Resolve a proc child --
4852        let proc_child_ref = &host_node.children[0];
4853        let proc_resp = admin_ref
4854            .resolve(&client, proc_child_ref.to_string())
4855            .await
4856            .unwrap();
4857        let proc_node = proc_resp.0.unwrap();
4858
4859        assert_eq!(
4860            proc_node.identity, *proc_child_ref,
4861            "identity must match the proc ref from the host's children list"
4862        );
4863
4864        assert!(
4865            matches!(proc_node.properties, NodeProperties::Proc { .. }),
4866            "expected NodeProperties::Proc, got {:?}",
4867            proc_node.properties
4868        );
4869
4870        let host_node_ref = crate::introspect::NodeRef::Host(host_agent_ref.actor_addr().clone());
4871        assert_eq!(
4872            proc_node.parent,
4873            Some(host_node_ref),
4874            "proc parent should be the host reference"
4875        );
4876
4877        // as_of is a SystemTime — just verify it's not the epoch.
4878        assert!(
4879            proc_node.as_of > std::time::UNIX_EPOCH,
4880            "as_of should be after the epoch"
4881        );
4882
4883        // Verify proc properties derived from attrs.
4884        assert!(
4885            matches!(&proc_node.properties, NodeProperties::Proc { .. }),
4886            "expected Proc properties"
4887        );
4888    }
4889
4890    // -- AdminHandle / PublishedHandle tests --
4891
4892    // AdminHandle::parse — all four cases.
4893    #[test]
4894    fn test_admin_handle_parse_https_url() {
4895        let h = super::AdminHandle::parse("https://myhost:1729");
4896        assert!(matches!(h, super::AdminHandle::Url(u) if u == "https://myhost:1729"));
4897    }
4898
4899    #[test]
4900    fn test_admin_handle_parse_bare_host_port() {
4901        // Bare host:port → inferred as https://host:port.
4902        let h = super::AdminHandle::parse("myhost:1729");
4903        assert!(
4904            matches!(h, super::AdminHandle::Url(ref u) if u == "https://myhost:1729"),
4905            "bare host:port should become https://host:port, got: {:?}",
4906            matches!(h, super::AdminHandle::Url(_))
4907        );
4908    }
4909
4910    #[test]
4911    fn test_admin_handle_parse_mast() {
4912        let h = super::AdminHandle::parse("mast_conda:///my-job");
4913        assert!(matches!(
4914            h,
4915            super::AdminHandle::Published(super::PublishedHandle::Mast(_))
4916        ));
4917    }
4918
4919    #[test]
4920    fn test_admin_handle_parse_unsupported() {
4921        // Bare hostname with no port → Unsupported (no port, scheme inference fails).
4922        let h = super::AdminHandle::parse("junk_hostname_no_port");
4923        assert!(matches!(h, super::AdminHandle::Unsupported(_)));
4924    }
4925
4926    #[tokio::test]
4927    async fn test_admin_handle_resolve_url_returns_url() {
4928        let h = super::AdminHandle::parse("https://myhost:1729");
4929        let result = h.resolve(None).await.unwrap();
4930        assert_eq!(result, "https://myhost:1729");
4931    }
4932
4933    #[tokio::test]
4934    async fn test_admin_handle_resolve_published_returns_error() {
4935        let h = super::AdminHandle::parse("mast_conda:///test-job");
4936        let err = format!("{:#}", h.resolve(Some(1729)).await.unwrap_err());
4937        assert!(
4938            err.contains("not yet implemented"),
4939            "expected 'not yet implemented' in error, got: {}",
4940            err
4941        );
4942    }
4943
4944    #[tokio::test]
4945    async fn test_admin_handle_resolve_unsupported_returns_error() {
4946        let h = super::AdminHandle::parse("junk_hostname_no_port");
4947        let err = format!("{:#}", h.resolve(None).await.unwrap_err());
4948        assert!(
4949            err.contains("unrecognized admin handle"),
4950            "expected 'unrecognized admin handle' in error, got: {}",
4951            err
4952        );
4953    }
4954
4955    // resolve_mast_handle delegates to PublishedHandle::resolve.
4956    // Error text changed from "disabled" to "not yet implemented".
4957    #[tokio::test]
4958    async fn test_resolve_mast_handle_returns_not_yet_implemented_error() {
4959        let result = super::resolve_mast_handle("mast_conda:///test-job", Some(1729)).await;
4960        let err = format!("{:#}", result.unwrap_err());
4961        assert!(
4962            err.contains("not yet implemented"),
4963            "expected 'not yet implemented' in error, got: {}",
4964            err
4965        );
4966    }
4967
4968    // -- AdminInfo::new() constructor tests --
4969
4970    // Constructor guarantee: valid https URL produces correct host.
4971    #[test]
4972    fn test_admin_info_new_derives_host_from_url() {
4973        let info = super::AdminInfo::new(
4974            "actor".to_string(),
4975            "proc".to_string(),
4976            "https://myhost.example.com:1729".to_string(),
4977        )
4978        .unwrap();
4979        assert_eq!(info.host, "myhost.example.com");
4980        assert_eq!(info.url, "https://myhost.example.com:1729");
4981    }
4982
4983    // Constructor guarantee: invalid URL is rejected.
4984    #[test]
4985    fn test_admin_info_new_rejects_invalid_url() {
4986        let result = super::AdminInfo::new(
4987            "actor".to_string(),
4988            "proc".to_string(),
4989            "not a url".to_string(),
4990        );
4991        assert!(result.is_err(), "invalid URL must be rejected");
4992    }
4993
4994    // Constructor guarantee: URL with no host is rejected.
4995    #[test]
4996    fn test_admin_info_new_rejects_url_without_host() {
4997        // data: URLs have no host component.
4998        let result = super::AdminInfo::new(
4999            "actor".to_string(),
5000            "proc".to_string(),
5001            "data:text/plain,hello".to_string(),
5002        );
5003        assert!(result.is_err(), "URL without host must be rejected");
5004    }
5005
5006    // -- Placement test (SA-5) --
5007
5008    // Exercises the real public entrypoint and checks SA-5 via
5009    // ActorRef reachability on the caller proc.
5010    #[tokio::test]
5011    async fn test_spawn_admin_places_on_caller_proc() {
5012        use hyperactor::Proc;
5013        use hyperactor::channel::ChannelTransport;
5014        use hyperactor::testing::proc_supervison::ProcSupervisionCoordinator;
5015
5016        use crate::host_mesh::HostMesh;
5017
5018        // 1. Stand up a local in-process host mesh.
5019        let host_mesh = HostMesh::local().await.unwrap();
5020
5021        // 2. Create a separate caller proc with an actor instance.
5022        let caller_proc = Proc::direct(ChannelTransport::Unix.any(), "caller".to_string()).unwrap();
5023        let _supervision = ProcSupervisionCoordinator::set(&caller_proc).await.unwrap();
5024        let caller_cx = caller_proc.client("caller");
5025
5026        // 3. Call the real public entrypoint.
5027        let admin_ref = crate::host_mesh::spawn_admin(
5028            [&host_mesh],
5029            &caller_cx,
5030            Some("[::]:0".parse().unwrap()),
5031            None,
5032        )
5033        .await
5034        .unwrap();
5035
5036        // 4. Prove the returned ActorRef is usable: fetch the URL
5037        //    via get_admin_addr. This also proves the admin is on
5038        //    caller_proc (undeliverable if not).
5039        let admin_url = admin_ref
5040            .get_admin_addr(&caller_cx)
5041            .await
5042            .unwrap()
5043            .addr
5044            .expect("SA-5: admin must report an address");
5045        assert!(
5046            !admin_url.is_empty(),
5047            "spawn_admin ref must yield a non-empty URL"
5048        );
5049    }
5050
5051    // AI-1..AI-3: GET /v1/admin HTTP route test requires TLS certs
5052    // (fbcode_build enforces mTLS). Covered by integration tests in
5053    // fbcode//monarch/hyperactor_mesh/test/mesh_admin_integration.
5054    // AI-4 is a constructor guarantee tested via AdminInfo::new() above.
5055
5056    // Verifies that GET /v1/{proc_id} reflects actors spawned directly
5057    // on a proc — bypassing ProcAgent's gspawn message and therefore
5058    // never triggering publish_introspect_properties — so that the
5059    // resolved children list is always derived from live proc state.
5060    //
5061    // Regression guard for the bug introduced in 9a08d559: the switch
5062    // from a live handle_introspect to a cached publish model made
5063    // supervision-spawned actors (e.g. every sieve actor after
5064    // sieve[0]) invisible to the TUI.
5065    //
5066    // Exercises PA-1 (see module doc). See also
5067    // proc_agent::tests::test_query_child_proc_returns_live_children.
5068    #[tokio::test]
5069    async fn test_proc_children_reflect_directly_spawned_actors() {
5070        use hyperactor::Proc;
5071        use hyperactor::actor::ActorStatus;
5072        use hyperactor::channel::ChannelTransport;
5073        use hyperactor::testing::proc_supervison::ProcSupervisionCoordinator;
5074
5075        use crate::host_mesh::host_agent::HostAgent;
5076        use crate::proc_agent::PROC_AGENT_ACTOR_NAME;
5077        use crate::proc_agent::ProcAgent;
5078
5079        // User proc: own ephemeral Unix socket, own ProcAgent.
5080        let user_proc =
5081            Proc::direct(ChannelTransport::Unix.any(), "user_proc".to_string()).unwrap();
5082        let agent_handle = ProcAgent::boot_v1(user_proc.clone(), None).unwrap();
5083        agent_handle
5084            .status()
5085            .wait_for(|s| matches!(s, ActorStatus::Idle))
5086            .await
5087            .unwrap();
5088
5089        // MeshAdminAgent: register a stand-in "host" on the user proc,
5090        // so it is the sole host at the user proc's address. It has no
5091        // QueryChild handler, so QueryChild → not_found → fallback to
5092        // proc_agent, the path being tested.
5093        // NOTE: Does not conform to SA-5 (caller-local placement).
5094        // White-box test of proc-agent fallback, not placement.
5095        let stand_in: ActorRef<HostAgent> = ActorRef::attest(
5096            user_proc
5097                .spawn_with_label("stand_in_host", TestIntrospectableActor)
5098                .bind::<TestIntrospectableActor>()
5099                .actor_addr()
5100                .clone(),
5101        );
5102        let admin_proc = Proc::direct(ChannelTransport::Unix.any(), "admin".to_string()).unwrap();
5103        let _supervision = ProcSupervisionCoordinator::set(&admin_proc).await.unwrap();
5104        let admin_handle = admin_proc.spawn_with_label(
5105            MESH_ADMIN_ACTOR_NAME,
5106            MeshAdminAgent::new(
5107                vec![(String::new(), stand_in)],
5108                None,
5109                Some("[::]:0".parse().unwrap()),
5110                None,
5111            ),
5112        );
5113        let admin_ref: ActorRef<MeshAdminAgent> = admin_handle.bind();
5114
5115        let client_proc = Proc::direct(ChannelTransport::Unix.any(), "client".to_string()).unwrap();
5116        let client = client_proc.client("client");
5117
5118        // Resolve the user proc via MeshAdminAgent. The stand-in
5119        // returns not_found for QueryChild → fallback to proc_agent[0]
5120        // QueryChild(Addr::Proc) → live NodeProperties::Proc.
5121        let user_proc_ref = user_proc.proc_addr().to_string();
5122        let resp = admin_ref
5123            .resolve(&client, user_proc_ref.clone())
5124            .await
5125            .unwrap();
5126        let node = resp.0.unwrap();
5127        assert!(
5128            matches!(node.properties, NodeProperties::Proc { .. }),
5129            "expected Proc, got {:?}",
5130            node.properties
5131        );
5132        let initial_count = node.children.len();
5133        assert!(
5134            node.children
5135                .iter()
5136                .any(|c| c.to_string().contains(PROC_AGENT_ACTOR_NAME)),
5137            "initial children {:?} should contain proc_agent",
5138            node.children
5139        );
5140
5141        // Spawn an actor directly on the user proc, bypassing gspawn.
5142        // This simulates how sieve[0] spawns sieve[1], sieve[2], etc.
5143        user_proc.spawn_with_label("extra_actor", TestIntrospectableActor);
5144
5145        // Resolve again — the new actor must appear immediately
5146        // without any republish, proving PA-1 is satisfied.
5147        let resp2 = admin_ref
5148            .resolve(&client, user_proc_ref.clone())
5149            .await
5150            .unwrap();
5151        let node2 = resp2.0.unwrap();
5152        assert!(
5153            matches!(node2.properties, NodeProperties::Proc { .. }),
5154            "expected Proc, got {:?}",
5155            node2.properties
5156        );
5157        assert!(
5158            node2
5159                .children
5160                .iter()
5161                .any(|c| c.to_string().contains("extra_actor")),
5162            "after direct spawn, children {:?} should contain extra_actor",
5163            node2.children
5164        );
5165        assert!(
5166            node2.children.len() > initial_count,
5167            "expected at least {} children after direct spawn, got {:?}",
5168            initial_count + 1,
5169            node2.children
5170        );
5171    }
5172
5173    // -- pyspy bridge input validation tests --
5174    //
5175    // Tests for the v1 proc-reference strictness contract (see
5176    // introspect module doc): the py-spy bridge accepts only
5177    // ProcAddr-form references and rejects other forms as bad_request.
5178
5179    #[test]
5180    fn pyspy_parse_empty_reference() {
5181        // v1 contract: empty input → bad_request.
5182        let err = parse_proc_reference("").unwrap_err();
5183        assert_eq!(err.code, "bad_request");
5184        assert!(err.message.contains("empty"));
5185    }
5186
5187    #[test]
5188    fn pyspy_parse_slash_only() {
5189        // v1 contract: slash-only (axum wildcard artifact) → bad_request.
5190        let err = parse_proc_reference("/").unwrap_err();
5191        assert_eq!(err.code, "bad_request");
5192        assert!(err.message.contains("empty"));
5193    }
5194
5195    #[test]
5196    fn pyspy_parse_malformed_percent_encoding() {
5197        // v1 contract: malformed encoding → bad_request.
5198        // %FF%FE is not valid UTF-8.
5199        let err = parse_proc_reference("%FF%FE").unwrap_err();
5200        assert_eq!(err.code, "bad_request");
5201        assert!(err.message.contains("percent-encoding"));
5202    }
5203
5204    #[test]
5205    fn pyspy_parse_invalid_proc_id() {
5206        // v1 contract: non-ProcAddr reference → bad_request.
5207        let err = parse_proc_reference("not-a-valid-proc-id").unwrap_err();
5208        assert_eq!(err.code, "bad_request");
5209        assert!(err.message.contains("invalid proc reference"));
5210    }
5211
5212    #[test]
5213    fn pyspy_parse_valid_proc_reference() {
5214        // v1 contract: valid ProcAddr → accepted.
5215        let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
5216        let proc_id = test_proc_id_with_addr(ChannelAddr::Tcp(addr), "myproc");
5217        let proc_id_str = proc_id.to_string();
5218
5219        let (decoded, parsed) = parse_proc_reference(&proc_id_str).unwrap();
5220        assert_eq!(decoded, proc_id_str);
5221        assert_eq!(parsed, proc_id);
5222    }
5223
5224    #[test]
5225    fn pyspy_parse_strips_leading_slash() {
5226        // v1 contract: leading slash from axum wildcard is stripped.
5227        let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
5228        let proc_id = test_proc_id_with_addr(ChannelAddr::Tcp(addr), "myproc");
5229        let with_slash = format!("/{}", proc_id);
5230
5231        let (_, parsed) = parse_proc_reference(&with_slash).unwrap();
5232        assert_eq!(parsed, proc_id);
5233    }
5234
5235    /// PS-12: a registered legacy service proc routes to HostAgent.
5236    #[test]
5237    fn route_proc_handler_service_proc_yields_host() {
5238        let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
5239        let proc_id = ResourceId::proc_addr_from_name(ChannelAddr::Tcp(addr), SERVICE_PROC_NAME);
5240        let host_agents = HashSet::from([HostAgentKey::new(
5241            &proc_id.actor_addr(HOST_MESH_AGENT_ACTOR_NAME),
5242        )]);
5243        let handler = route_proc_handler(&host_agents, &proc_id.to_string()).unwrap();
5244        assert!(
5245            matches!(handler, ResolvedProcHandler::Host(_)),
5246            "a registered legacy service proc should resolve to Host"
5247        );
5248    }
5249
5250    /// PS-12: an unregistered worker proc routes to ProcAgent.
5251    #[test]
5252    fn route_proc_handler_worker_proc_yields_proc() {
5253        let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
5254        let proc_id = test_proc_id_with_addr(ChannelAddr::Tcp(addr), "worker_0");
5255        let handler = route_proc_handler(&HashSet::new(), &proc_id.to_string()).unwrap();
5256        assert!(
5257            matches!(handler, ResolvedProcHandler::Proc(_)),
5258            "an unregistered worker proc should resolve to Proc"
5259        );
5260    }
5261
5262    /// PS-12: a registered instance proc routes to HostAgent regardless of label.
5263    #[test]
5264    fn route_proc_handler_registered_instance_yields_host() {
5265        let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
5266        let proc_id = ProcAddr::new(
5267            ProcId::instance(Label::strip("host-control")),
5268            ChannelAddr::Tcp(addr).into(),
5269        );
5270        let host_agents = HashSet::from([HostAgentKey::new(
5271            &proc_id.actor_addr(HOST_MESH_AGENT_ACTOR_NAME),
5272        )]);
5273        let handler = route_proc_handler(&host_agents, &proc_id.to_string()).unwrap();
5274        assert!(
5275            matches!(handler, ResolvedProcHandler::Host(_)),
5276            "a registered proc should resolve to Host regardless of its label"
5277        );
5278    }
5279
5280    #[test]
5281    fn route_proc_handler_service_label_without_host_registration_yields_proc() {
5282        let addr: SocketAddr = "127.0.0.1:9000".parse().unwrap();
5283        let proc_id = ProcAddr::new(
5284            ProcId::instance(Label::strip(SERVICE_PROC_NAME)),
5285            ChannelAddr::Tcp(addr).into(),
5286        );
5287        let handler = route_proc_handler(&HashSet::new(), &proc_id.to_string()).unwrap();
5288        assert!(
5289            matches!(handler, ResolvedProcHandler::Proc(_)),
5290            "an unregistered proc must not become a host service based on its label"
5291        );
5292    }
5293}