//! Agent topology storage — single source of truth for parent/child //! relations in the hive. Persisted as a flat JSON map of `name → //! parent name | null` at `/var/lib/hyperhive/meta/topology.json`, //! alongside the meta `flake.nix`, so topology changes thread through //! the same git commit log as deploys. //! //! Agent roles are stored alongside in `roles.json` as a flat map of //! `name → [role, ...]`. Roles gate additional bind-mount grants; see //! `lifecycle::set_nspawn_flags` for the consumer. //! //! Format, rationale, read/reconcile/inject/surface flow, and target //! enforcement semantics: `docs/agent-hierarchy.md::Current state`. //! `` sentinel resolution (delivered by [`resolve_recipient`]): //! `docs/conventions.md::Recipient sentinels`. use std::collections::BTreeMap; use std::path::PathBuf; const TOPOLOGY_FILE: &str = "topology.json"; #[must_use] pub fn topology_path() -> PathBuf { crate::meta::meta_dir().join(TOPOLOGY_FILE) } /// Snapshot of the topology map. Read on every `container_view::build_all` /// and every `render_flake` call. The file is small (one line per agent), /// so we re-read rather than caching — keeps the source of truth on disk. /// /// Returns an empty map when the file is absent or unparsable; callers /// treat that as "no recorded parents", which falls back to every agent /// being root-level. Safe degradation for fresh installs that haven't /// run through `meta::sync_agents` yet. #[must_use] pub fn read() -> BTreeMap> { let path = topology_path(); let Ok(raw) = std::fs::read_to_string(&path) else { return BTreeMap::new(); }; serde_json::from_str(&raw).unwrap_or_default() } /// Look up one agent's parent. Returns `None` when the agent is root /// or absent from the file. Cheap convenience over `read()` for /// callers that want a single entry. #[must_use] pub fn parent_of(name: &str) -> Option { read().get(name).cloned().flatten() } /// Return the direct children of `name` — agents whose `topology.json` /// entry has `name` as their parent. Reads the map once and scans all /// entries; cheap enough for the fan-out path (one disk read per send /// to ``). #[must_use] pub fn children_of(name: &str) -> Vec { children_of_in(&read(), name) } /// Pure form of [`children_of`] for unit tests. #[must_use] pub fn children_of_in(topo: &BTreeMap>, name: &str) -> Vec { topo.iter() .filter_map(|(agent, parent)| { if parent.as_deref() == Some(name) { Some(agent.clone()) } else { None } }) .collect() } /// Return every agent that has no parent in the topology. These are the /// "top-level" agents a `can_manage_top_level_agents` role holder is /// granted access to. No agent name is hardcoded — the set is derived /// purely from topology structure. /// /// In normal operation this is just the manager, but any agent the /// operator explicitly places outside the hierarchy is also included. #[must_use] pub fn top_level_agents() -> Vec { top_level_agents_in(&read()) } /// Pure form of [`top_level_agents`] for unit tests. #[must_use] pub fn top_level_agents_in(topo: &BTreeMap>) -> Vec { topo.iter() .filter_map(|(name, parent)| { if parent.is_none() { Some(name.clone()) } else { None } }) .collect() } /// Resolve a magic recipient sentinel (currently just /// [`hive_sh4re::PARENT_RECIPIENT`]) to a real broker recipient at /// send time. Returns an owned `String` so callers can plug it /// straight into [`crate::broker::Broker::send`] without /// borrow-juggling around the temporary lookup. /// /// Rules + rationale: `docs/conventions.md::Recipient sentinels`. /// Fast path: ordinary recipient names short-circuit before any /// disk read — only `` triggers `read()` on `topology.json`. #[must_use] pub fn resolve_recipient(sender: &str, to: &str) -> String { // Early exit: only sentinel recipients need topology lookup. This // keeps the cost of a normal `send` at one string comparison. if to != hive_sh4re::PARENT_RECIPIENT { return to.to_owned(); } resolve_recipient_in(&read(), sender, to) } /// Pure form of [`resolve_recipient`] taking the topology map /// explicitly. Split out so unit tests can exercise the sentinel /// rules without writing a `topology.json` to disk. #[must_use] pub fn resolve_recipient_in( topo: &BTreeMap>, sender: &str, to: &str, ) -> String { if to == hive_sh4re::PARENT_RECIPIENT { topo.get(sender) .cloned() .flatten() .unwrap_or_else(|| hive_sh4re::OPERATOR_RECIPIENT.to_owned()) } else { to.to_owned() } } /// True when `candidate` is `ancestor` or any descendant of /// `ancestor` per the current topology. Walks parents from /// `candidate` upward; the walk terminates at root or on a cycle /// (cycle defence: bounded to 32 hops, more than any plausible /// hive depth). Used by the cancel-authorization check in /// `manager_server::handle_cancel_schedule` to enforce /// "managers can cancel anything their subtree owns." #[must_use] pub fn is_descendant_of(candidate: &str, ancestor: &str) -> bool { if candidate == ancestor { return true; } let topo = read(); let mut cur = candidate.to_owned(); for _ in 0..32 { let Some(parent) = topo.get(&cur).cloned().flatten() else { return false; }; if parent == ancestor { return true; } cur = parent; } false } /// Persist the topology map. Sorted JSON output (`BTreeMap` is sorted by /// key) keeps git diffs minimal across re-writes. Best-effort — /// returns an `io::Error` so callers can decide whether a failure /// should abort their op (`sync_agents`, `RequestSetParent`) or just log. pub fn write(topology: &BTreeMap>) -> std::io::Result<()> { let path = topology_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let text = serde_json::to_string_pretty(topology) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; std::fs::write(&path, format!("{text}\n")) } /// Compute the default topology for a fresh install: every non-manager /// agent has the manager as parent; manager itself is root. Used by /// `meta::sync_agents` on first call to seed `topology.json`. /// /// As soon as an explicit write lands (dashboard / `RequestSetParent` /// API), this seeding stops touching pre-existing entries — /// `sync_agents` only adds rows for newly-spawned agents against /// whatever the operator has configured. #[must_use] #[allow( dead_code, reason = "kept for the dashboard / RequestSetParent write API; \ `sync_agents` does its own seeding today" )] pub fn default_seed(agent_names: &[String]) -> BTreeMap> { let mut out = BTreeMap::new(); for name in agent_names { if name == crate::lifecycle::MANAGER_NAME { out.insert(name.clone(), None); } else { out.insert( name.clone(), Some(crate::lifecycle::MANAGER_NAME.to_owned()), ); } } out } /// Pure validation + apply for [`set_parent`]. Splits off so tests /// can exercise the rules (cycle / unknown) on an in-memory /// `BTreeMap` without touching the on-disk `topology.json`. Returns /// either the post-move map (caller writes it back) or a /// user-readable error string. /// /// The manager is reparentable like any other agent — its special /// powers come from the privileged MCP socket, not its tree /// position. The cycle walk below covers "moving X under its own /// descendant" for the manager as much as any other agent. /// `docs/agent-hierarchy.md::Current state` has the rationale. pub fn apply_set_parent( topo: &BTreeMap>, child: &str, new_parent: Option<&str>, ) -> Result>, String> { if !topo.contains_key(child) { return Err(format!("unknown agent: {child}")); } if let Some(p) = new_parent { if !topo.contains_key(p) { return Err(format!("unknown parent: {p}")); } if p == child { return Err("an agent cannot be its own parent".to_owned()); } // Cycle check: walk `p`'s ancestors in the EXISTING map. If // we hit `child`, then making `child`'s parent = `p` would // close the loop (child → … → p → child). let mut cur = p.to_owned(); for _ in 0..32 { if cur == child { return Err(format!( "cycle: {p} is in {child}'s subtree (would create a loop)" )); } let Some(next) = topo.get(&cur).cloned().flatten() else { break; }; cur = next; } } let mut next = topo.clone(); next.insert(child.to_owned(), new_parent.map(str::to_owned)); Ok(next) } /// Operator-driven parent move. Set `child`'s parent to `new_parent` /// (or `None` to promote to root). See [`apply_set_parent`] for the /// validation rules. The operator-set parent sticks across /// `reconcile()` calls (which preserves existing entries). /// /// No bind-mount / container churn today — the hierarchy is /// currently logical-only. Once sub-manager bind mounts land, the /// caller adds an umount-old / mount-new / restart-cascade step on /// top. pub fn set_parent(child: &str, new_parent: Option<&str>) -> Result<(), String> { let current = read(); // Idempotent no-op fast path: skip the disk write when nothing // changes. apply_set_parent still runs to surface validation // errors (e.g. unknown child) so the caller gets a real signal. let next = apply_set_parent(¤t, child, new_parent)?; if next == current { return Ok(()); } write(&next).map_err(|e| format!("write topology.json: {e}")) } /// Reconcile `topology.json` against the current agent set. Adds an /// entry (default: parent = manager, manager itself = root) for any /// agent missing from the file; removes entries for agents no longer /// present. Existing entries are preserved as-is — operator/manager /// choices stick across regenerations. Returns true when the file /// changed and should be re-committed by the caller. pub fn reconcile(agent_names: &[String]) -> std::io::Result { let mut current = read(); let mut changed = false; // Add missing agents at their default position. for name in agent_names { if !current.contains_key(name) { let parent = if name == crate::lifecycle::MANAGER_NAME { None } else { Some(crate::lifecycle::MANAGER_NAME.to_owned()) }; current.insert(name.clone(), parent); changed = true; } } // Drop entries for agents that no longer exist. let known: std::collections::HashSet<_> = agent_names.iter().collect(); current.retain(|name, _| { let keep = known.contains(name); if !keep { changed = true; } keep }); if changed { write(¤t)?; } // Keep roles in sync with the agent set. reconcile_roles(agent_names)?; Ok(changed) } // --------------------------------------------------------------------------- // Roles // --------------------------------------------------------------------------- /// Agents with this role have every parentless agent in the topology /// (see `top_level_agents`) added as virtual children for bind-mount /// and config-change purposes. Enables recovery: if a top-level agent /// is down, a role holder can still read its state and update its config. /// /// The manager receives this role by default on first `reconcile_roles` /// call; operators can revoke it with `set_role`. pub const ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS: &str = "can_manage_top_level_agents"; const ROLES_FILE: &str = "roles.json"; #[must_use] pub fn roles_path() -> std::path::PathBuf { crate::meta::meta_dir().join(ROLES_FILE) } /// Read the roles map from disk. Returns an empty map when absent or /// unparsable — same safe-degradation pattern as `topology::read`. #[must_use] pub fn read_roles() -> BTreeMap> { let Ok(raw) = std::fs::read_to_string(roles_path()) else { return BTreeMap::new(); }; serde_json::from_str(&raw).unwrap_or_default() } /// Persist the roles map. Sorted output keeps git diffs minimal. pub fn write_roles(roles: &BTreeMap>) -> std::io::Result<()> { let path = roles_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let text = serde_json::to_string_pretty(roles) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; std::fs::write(&path, format!("{text}\n")) } /// Return true when `name` holds `role`. #[must_use] pub fn has_role(name: &str, role: &str) -> bool { has_role_in(&read_roles(), name, role) } /// Pure form of [`has_role`] for unit tests. #[must_use] pub fn has_role_in(roles: &BTreeMap>, name: &str, role: &str) -> bool { roles .get(name) .is_some_and(|rs| rs.iter().any(|r| r == role)) } /// Grant or revoke a role for `name`. Idempotent — no disk write when the /// state is already correct. /// /// Empty role lists are kept in the map (never removed). An absent key means /// "never seen" (seed on next `reconcile_roles`); an empty list means /// "explicitly revoked" (do not re-seed). Callers that want to remove an /// agent from the map entirely should use `reconcile_roles` (agent departure). pub fn set_role(name: &str, role: &str, enabled: bool) -> Result<(), String> { let mut roles = read_roles(); let list = roles.entry(name.to_owned()).or_default(); let held = list.iter().any(|r| r == role); match (enabled, held) { (true, false) => list.push(role.to_owned()), (false, true) => list.retain(|r| r != role), _ => return Ok(()), } // Intentionally do NOT remove empty entries — an empty list signals an // explicit revoke and prevents reconcile_roles from re-seeding the role. write_roles(&roles).map_err(|e| format!("write roles.json: {e}")) } /// Reconcile `roles.json` against the current agent set: /// - Seeds root's default `can_manage_top_level_agents` role on first /// appearance (operator can revoke with `set_role`). /// - Drops entries for agents that no longer exist. /// /// Returns true when the file changed. pub fn reconcile_roles(agent_names: &[String]) -> std::io::Result { let mut roles = read_roles(); let mut changed = false; let root = crate::lifecycle::MANAGER_NAME; if agent_names.iter().any(|n| n == root) && !roles.contains_key(root) { roles.insert( root.to_owned(), vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()], ); changed = true; } let known: std::collections::HashSet<_> = agent_names.iter().collect(); roles.retain(|name, _| { let keep = known.contains(name); if !keep { changed = true; } keep }); if changed { write_roles(&roles)?; } Ok(changed) } #[cfg(test)] mod tests { use super::*; #[test] fn default_seed_makes_manager_root_others_children() { let agents = vec![ "alice".to_owned(), crate::lifecycle::MANAGER_NAME.to_owned(), "bob".to_owned(), ]; let seed = default_seed(&agents); assert_eq!( seed.get(crate::lifecycle::MANAGER_NAME), Some(&None), "manager should be root" ); assert_eq!( seed.get("alice"), Some(&Some(crate::lifecycle::MANAGER_NAME.to_owned())) ); assert_eq!( seed.get("bob"), Some(&Some(crate::lifecycle::MANAGER_NAME.to_owned())) ); } #[test] fn default_seed_handles_empty_input() { let seed = default_seed(&[]); assert!(seed.is_empty()); } fn topo_three_level() -> BTreeMap> { let mut m = BTreeMap::new(); m.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None); m.insert( "alice".to_owned(), Some(crate::lifecycle::MANAGER_NAME.to_owned()), ); m.insert("bob".to_owned(), Some("alice".to_owned())); m.insert("carol".to_owned(), Some("alice".to_owned())); m } #[test] fn apply_set_parent_promotes_to_root() { let next = apply_set_parent(&topo_three_level(), "alice", None).unwrap(); assert_eq!(next.get("alice"), Some(&None)); } #[test] fn apply_set_parent_reparents_under_sibling_subtree() { // bob and carol both under alice; move carol under bob. let next = apply_set_parent(&topo_three_level(), "carol", Some("bob")).unwrap(); assert_eq!(next.get("carol"), Some(&Some("bob".to_owned()))); } #[test] fn apply_set_parent_allows_manager_move() { // The manager is reparentable like any other agent (its // privileges live on the MCP socket, not its tree position). // Build a topo with an unrelated root-level agent `peer` so // moving the manager under it doesn't trip the cycle walk // (every non-manager agent in topo_three_level descends from // the manager, so that fixture can't exercise a legal // manager move). let mut topo = BTreeMap::new(); topo.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None); topo.insert("peer".to_owned(), None); let next = apply_set_parent(&topo, crate::lifecycle::MANAGER_NAME, Some("peer")) .expect("manager move should succeed"); assert_eq!( next.get(crate::lifecycle::MANAGER_NAME), Some(&Some("peer".to_owned())) ); } #[test] fn apply_set_parent_refuses_manager_under_own_descendant() { // Moving the manager under `bob` (who already lives under // `alice` who lives under the manager) would close the loop. // The general cycle walk catches this; no separate manager // guard needed. let err = apply_set_parent( &topo_three_level(), crate::lifecycle::MANAGER_NAME, Some("bob"), ) .unwrap_err(); assert!(err.contains("cycle"), "err = {err}"); } #[test] fn apply_set_parent_refuses_unknown_child() { let err = apply_set_parent(&topo_three_level(), "nobody", Some("alice")).unwrap_err(); assert!(err.contains("unknown agent"), "err = {err}"); } #[test] fn apply_set_parent_refuses_unknown_parent() { let err = apply_set_parent(&topo_three_level(), "bob", Some("nobody")).unwrap_err(); assert!(err.contains("unknown parent"), "err = {err}"); } #[test] fn apply_set_parent_refuses_self() { let err = apply_set_parent(&topo_three_level(), "alice", Some("alice")).unwrap_err(); assert!(err.contains("own parent"), "err = {err}"); } #[test] fn apply_set_parent_refuses_cycle() { // bob's parent is alice; trying to make alice's parent = // bob would close the loop alice → bob → alice. let err = apply_set_parent(&topo_three_level(), "alice", Some("bob")).unwrap_err(); assert!(err.contains("cycle"), "err = {err}"); } #[test] fn apply_set_parent_refuses_deep_cycle() { // Three-deep chain: manager → alice → bob → carol. Moving // alice under carol would create the loop alice → carol → bob → alice. let mut topo = BTreeMap::new(); topo.insert(crate::lifecycle::MANAGER_NAME.to_owned(), None); topo.insert( "alice".to_owned(), Some(crate::lifecycle::MANAGER_NAME.to_owned()), ); topo.insert("bob".to_owned(), Some("alice".to_owned())); topo.insert("carol".to_owned(), Some("bob".to_owned())); let err = apply_set_parent(&topo, "alice", Some("carol")).unwrap_err(); assert!(err.contains("cycle"), "err = {err}"); } #[test] fn apply_set_parent_is_idempotent_noop() { // bob is already under alice — same value returned. let next = apply_set_parent(&topo_three_level(), "bob", Some("alice")).unwrap(); assert_eq!(next, topo_three_level()); } #[test] fn resolve_recipient_passes_through_ordinary_names() { let topo = topo_three_level(); // Real labels, broadcast, and the operator literal all // shortcut through unchanged — no resolution magic. assert_eq!(resolve_recipient_in(&topo, "bob", "alice"), "alice"); assert_eq!(resolve_recipient_in(&topo, "bob", "*"), "*"); assert_eq!( resolve_recipient_in(&topo, "bob", hive_sh4re::OPERATOR_RECIPIENT), hive_sh4re::OPERATOR_RECIPIENT ); } #[test] fn resolve_recipient_rewrites_parent_sentinel_to_parent_label() { let topo = topo_three_level(); // bob's parent is alice → `` from bob goes to alice. assert_eq!( resolve_recipient_in(&topo, "bob", hive_sh4re::PARENT_RECIPIENT), "alice" ); // alice's parent is the manager — same one-hop rewrite. assert_eq!( resolve_recipient_in(&topo, "alice", hive_sh4re::PARENT_RECIPIENT), crate::lifecycle::MANAGER_NAME ); } #[test] fn resolve_recipient_falls_back_to_operator_for_root_agent() { let topo = topo_three_level(); // Manager is structurally root (parent = None) → `` // resolves to the operator (the "no parent → tell mara" // fallback documented in conventions.md). assert_eq!( resolve_recipient_in( &topo, crate::lifecycle::MANAGER_NAME, hive_sh4re::PARENT_RECIPIENT ), hive_sh4re::OPERATOR_RECIPIENT ); } #[test] fn resolve_recipient_falls_back_to_operator_for_unknown_sender() { // Sender absent from topology entirely — defensive fallback // covers the race window where an agent's spawn has registered // its socket but the meta-flake `sync_agents` hasn't yet added // its row. let topo = topo_three_level(); assert_eq!( resolve_recipient_in(&topo, "nobody", hive_sh4re::PARENT_RECIPIENT), hive_sh4re::OPERATOR_RECIPIENT ); } #[test] fn children_of_in_returns_direct_descendants() { let topo = topo_three_level(); // alice's children: bob, carol. let mut children = children_of_in(&topo, "alice"); children.sort(); assert_eq!(children, vec!["bob", "carol"]); } #[test] fn children_of_in_manager_returns_root_level_agents() { let topo = topo_three_level(); // Only alice's parent is manager; bob+carol are under alice. let children = children_of_in(&topo, crate::lifecycle::MANAGER_NAME); assert_eq!(children, vec!["alice"]); } #[test] fn children_of_in_leaf_returns_empty() { let topo = topo_three_level(); // bob and carol have no children. assert!(children_of_in(&topo, "bob").is_empty()); assert!(children_of_in(&topo, "carol").is_empty()); } #[test] fn children_of_in_unknown_sender_returns_empty() { let topo = topo_three_level(); assert!(children_of_in(&topo, "nobody").is_empty()); } #[test] fn top_level_agents_in_returns_parentless_agents() { let topo = topo_three_level(); // Only the manager has no parent (alice/bob/carol all have parents). let top = top_level_agents_in(&topo); assert_eq!(top, vec![crate::lifecycle::MANAGER_NAME]); } #[test] fn top_level_agents_in_multi_root_returns_all_parentless() { let mut topo = topo_three_level(); // Simulate a second parentless agent alongside the manager. topo.insert("orphan".to_owned(), None); let mut top = top_level_agents_in(&topo); top.sort(); let mut expected = vec![crate::lifecycle::MANAGER_NAME, "orphan"]; expected.sort_unstable(); assert_eq!(top, expected); } #[test] fn top_level_agents_in_empty_topo_returns_empty() { let topo = BTreeMap::new(); assert!(top_level_agents_in(&topo).is_empty()); } // ----------------------------------------------------------------------- // Roles tests (no disk I/O — use the pure `has_role_in` / in-memory maps) // ----------------------------------------------------------------------- #[test] fn has_role_in_returns_true_when_role_held() { let mut roles = BTreeMap::new(); roles.insert( "alice".to_owned(), vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()], ); assert!(has_role_in( &roles, "alice", ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS )); } #[test] fn has_role_in_returns_false_for_absent_agent() { let roles: BTreeMap> = BTreeMap::new(); assert!(!has_role_in( &roles, "alice", ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS )); } #[test] fn has_role_in_returns_false_for_empty_list() { let mut roles = BTreeMap::new(); roles.insert("alice".to_owned(), vec![]); assert!(!has_role_in( &roles, "alice", ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS )); } /// Revoking a role must leave the key present with an empty list so /// `reconcile_roles` does not re-seed it. #[test] fn set_role_revoke_keeps_empty_entry_as_tombstone() { let mgr = crate::lifecycle::MANAGER_NAME; // Build an in-memory roles map as set_role would see it after granting. let mut roles: BTreeMap> = BTreeMap::new(); roles.insert( mgr.to_owned(), vec![ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS.to_owned()], ); // Simulate the revoke path of set_role (in-memory, no disk). let list = roles.entry(mgr.to_owned()).or_default(); let held = list.iter().any(|r| r == ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS); assert!(held); list.retain(|r| r != ROLE_CAN_MANAGE_TOP_LEVEL_AGENTS); // Key must still be present (tombstone), just with an empty list. assert!(roles.contains_key(mgr), "empty entry must not be removed"); assert!(roles[mgr].is_empty()); } /// `reconcile_roles` must not re-seed the manager when its entry exists /// but is empty (operator explicitly revoked the role). #[test] fn reconcile_roles_in_does_not_reseed_after_explicit_revoke() { let mgr = crate::lifecycle::MANAGER_NAME; let agent_names = [mgr.to_owned(), "alice".to_owned()]; let mut roles: BTreeMap> = BTreeMap::new(); // Tombstone: manager was seen before but all roles were revoked. roles.insert(mgr.to_owned(), vec![]); let mgr_present = agent_names.iter().any(|n| n == mgr); let should_seed = mgr_present && !roles.contains_key(mgr); // should_seed must be false because manager key is present (tombstone). assert!( !should_seed, "reconcile_roles must not re-seed an explicit revoke" ); } /// `reconcile_roles` seeds the manager on first appearance (no prior entry). #[test] fn reconcile_roles_in_seeds_root_when_absent() { let mgr = crate::lifecycle::MANAGER_NAME; let agent_names = [mgr.to_owned(), "alice".to_owned()]; let roles: BTreeMap> = BTreeMap::new(); // empty let should_seed = agent_names.iter().any(|n| n == mgr) && !roles.contains_key(mgr); assert!(should_seed, "reconcile_roles must seed manager when absent"); } }