Skip to main content

ui_automata/
shadow_dom.rs

1/// Shadow DOM: a cached registry of named live element handles.
2///
3/// Anchors are declared with a tier and a selector path relative to a named
4/// parent (or the desktop root). The registry holds live handles and re-queries
5/// them transparently on staleness using a walk-up strategy.
6use std::collections::HashMap;
7
8use crate::{
9    AutomataError, Browser, Desktop, Element, SelectorPath, TabHandle,
10    node_cache::{NodeCache, is_live},
11    registry::{AnchorDef, LaunchContext, LaunchWait, Tier},
12    snapshot::{SNAP_DEPTH, SnapNode},
13};
14
15// ── AnchorName / AnchorMeta ───────────────────────────────────────────────────
16
17/// Newtype key for anchor names. Prevents accidentally mixing anchor-name strings
18/// with other `String` values, and enables typed `HashMap` lookups.
19#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20struct AnchorName(String);
21
22impl AnchorName {
23    fn new(s: impl Into<String>) -> Self {
24        AnchorName(s.into())
25    }
26}
27
28impl std::borrow::Borrow<str> for AnchorName {
29    fn borrow(&self) -> &str {
30        &self.0
31    }
32}
33
34/// Lock state captured on first resolution of a root-tier anchor.
35/// Kept in a single map so HWND and PID always travel together.
36#[derive(Debug, Clone)]
37struct AnchorMeta {
38    /// Exact window handle. Re-resolution fails if this HWND is gone rather
39    /// than drifting to a different window of the same process.
40    hwnd: Option<u64>,
41    /// PID of the owning process. Kept for `anchor_pid()` callers.
42    pid: u32,
43}
44
45// ── ShadowDom ─────────────────────────────────────────────────────────────────
46
47/// A cached registry of named live element handles. Does not own the desktop;
48/// callers pass `&D` to the methods that need to re-query the UIA tree.
49pub struct ShadowDom<D: Desktop> {
50    /// Declared topology — all registered anchor definitions.
51    defs: HashMap<String, AnchorDef>,
52    /// Anchor handles and selector find-result cache.
53    nodes: NodeCache<D::Elem>,
54    /// Last-known tree snapshot per anchor, used by `sync` to detect changes.
55    snapshots: HashMap<String, SnapNode>,
56    /// Lock state captured on first resolution of each root anchor.
57    /// Combines HWND (for drift prevention) and PID (for `anchor_pid()`)
58    /// in a single typed map.
59    locks: HashMap<AnchorName, AnchorMeta>,
60    /// Launch context set after a successful `launch:` wait. Used to filter
61    /// the first resolution of root anchors: `new_pid` filters by PID,
62    /// `new_window` excludes pre-existing HWNDs, `match_any` applies no extra filter.
63    launch_context: Option<LaunchContext>,
64    /// Current subflow depth. At depth 0 all anchors use their raw name.
65    /// At depth N, Stable/Ephemeral anchors are stored under `":".repeat(N) + name`.
66    depth: usize,
67    /// Tab anchor handles: keyed by depth-prefixed anchor name.
68    tab_handles: HashMap<String, TabHandle>,
69}
70
71impl<D: Desktop> ShadowDom<D> {
72    pub fn new() -> Self {
73        ShadowDom {
74            defs: HashMap::new(),
75            nodes: NodeCache::new(),
76            snapshots: HashMap::new(),
77            locks: HashMap::new(),
78            launch_context: None,
79            depth: 0,
80            tab_handles: HashMap::new(),
81        }
82    }
83
84    /// Set the current subflow depth. At depth 0, all anchors use their raw name.
85    /// At depth N, Stable/Ephemeral anchors are stored under `":".repeat(N) + name`.
86    pub fn set_depth(&mut self, depth: usize) {
87        self.depth = depth;
88    }
89
90    /// Get the current subflow depth.
91    pub fn depth(&self) -> usize {
92        self.depth
93    }
94
95    /// Return the depth-prefixed version of a name.
96    fn prefixed(&self, name: &str) -> String {
97        ":".repeat(self.depth) + name
98    }
99
100    /// Compute the effective storage key for `raw_name` at the current depth.
101    ///
102    /// At depth 0, returns `raw_name` unchanged.
103    /// At depth N, tries the prefixed key first; falls back to `raw_name` if the
104    /// prefixed key is not registered (allows child workflows to reference Root/Session
105    /// anchors inherited from the parent).
106    fn effective_key(&self, raw_name: &str) -> String {
107        if self.depth == 0 {
108            return raw_name.to_string();
109        }
110        let prefixed = self.prefixed(raw_name);
111        if self.defs.contains_key(&prefixed) || self.nodes.element(&prefixed).is_some() {
112            prefixed
113        } else {
114            raw_name.to_string()
115        }
116    }
117
118    /// Store the launch context so root-anchor first-resolutions use it for filtering.
119    pub fn set_launch_context(&mut self, ctx: LaunchContext) {
120        self.launch_context = Some(ctx);
121    }
122
123    /// Register anchor definitions and immediately resolve Root-tier anchors.
124    pub fn mount(&mut self, anchors: Vec<AnchorDef>, desktop: &D) -> Result<(), AutomataError> {
125        let mut new_root_keys: Vec<String> = Vec::new();
126        let mut new_browser_keys: Vec<String> = Vec::new();
127        let mut new_tab_keys: Vec<String> = Vec::new();
128
129        for mut def in anchors {
130            let key = match def.tier {
131                Tier::Root | Tier::Session | Tier::Browser => {
132                    // Globally shared: if already registered under the raw name, skip (parent wins).
133                    if self.defs.contains_key(&def.name) {
134                        continue;
135                    }
136                    def.name.clone()
137                }
138                Tier::Stable | Tier::Ephemeral | Tier::Tab => {
139                    // Depth-scoped.
140                    self.prefixed(&def.name)
141                }
142            };
143            def.mount_depth = self.depth;
144            match def.tier {
145                Tier::Root => new_root_keys.push(key.clone()),
146                Tier::Browser => new_browser_keys.push(key.clone()),
147                Tier::Tab => new_tab_keys.push(key.clone()),
148                _ => {}
149            }
150            self.defs.insert(key, def);
151        }
152
153        // Resolve Browser anchors eagerly: call ensure() then find the browser window via UIA.
154        for key in new_browser_keys {
155            if self.nodes.element(&key).is_none() {
156                if let Err(e) = self.resolve(&key, desktop) {
157                    self.defs.remove(&key);
158                    return Err(e);
159                }
160            }
161        }
162
163        // Resolve newly registered Root anchors eagerly — they represent the
164        // top-level application window and must always be present.
165        // Non-root anchors (Stable, Ephemeral) are resolved lazily on first use.
166        for key in new_root_keys {
167            if self.nodes.element(&key).is_none() {
168                if let Err(e) = self.resolve(&key, desktop) {
169                    // Rollback: remove the def so a subsequent mount() call can
170                    // re-register and retry resolution (e.g. when the window is
171                    // still appearing after launch with wait: match_any).
172                    self.defs.remove(&key);
173                    return Err(e);
174                }
175            }
176        }
177
178        // Mount Tab anchors: open or attach to CDP tabs.
179        for key in new_tab_keys {
180            if !self.tab_handles.contains_key(&key) {
181                let def = match self.defs.get(&key) {
182                    Some(d) => d.clone(),
183                    None => continue,
184                };
185                match TabHandle::mount(&def, desktop.browser()) {
186                    Ok(handle) => {
187                        self.tab_handles.insert(key.to_string(), handle);
188                    }
189                    Err(e) => {
190                        self.defs.remove(&key);
191                        return Err(e);
192                    }
193                }
194            }
195        }
196
197        Ok(())
198    }
199
200    /// Insert an element directly into the cache (used for Ephemeral captures).
201    pub fn insert(&mut self, name: impl Into<String>, element: D::Elem) {
202        let raw_name = name.into();
203        let key = self.prefixed(&raw_name);
204        let id = self.nodes.insert(key.clone(), element);
205        log::debug!("inserted node {id} for anchor '{key}'");
206    }
207
208    /// Retrieve a live handle by name. Re-queries if the cached handle is stale.
209    ///
210    /// Returns `Err` if the anchor cannot be resolved (root gone, selector
211    /// not found after walk-up, or name not registered).
212    pub fn get(&mut self, name: &str, desktop: &D) -> Result<&D::Elem, AutomataError> {
213        let key = self.effective_key(name);
214
215        // Tab anchors have no UIA element of their own; delegate to the parent Browser anchor.
216        let parent_redirect = self
217            .defs
218            .get(&key)
219            .filter(|d| d.tier == Tier::Tab)
220            .and_then(|d| d.parent.clone());
221        if let Some(parent) = parent_redirect {
222            return self.get(&parent, desktop);
223        }
224
225        let old_id = self.nodes.node_id(&key);
226        let cached_live = self.nodes.anchor_is_live(&key);
227
228        if !cached_live {
229            if let Some(stale) = self.nodes.remove(&key) {
230                log::debug!("invalidated node {} for anchor '{key}'", stale.id);
231            }
232            self.resolve(&key, desktop)?;
233            let new_id = self.nodes.node_id(&key).unwrap_or(0);
234            match old_id {
235                Some(oid) => {
236                    log::debug!("replaced node {oid} with node {new_id} for anchor '{key}'")
237                }
238                None => log::debug!("mounted node {new_id} for anchor '{key}'"),
239            }
240        }
241
242        self.nodes.element(&key).ok_or_else(|| {
243            AutomataError::Internal(format!("anchor '{name}' not found after resolve"))
244        })
245    }
246
247    /// Remove a session anchor and all stable anchors that depend on it.
248    pub fn invalidate_session(&mut self, session_name: &str) {
249        self.locks.remove(session_name);
250        if let Some(n) = self.nodes.remove(session_name) {
251            log::debug!(
252                "invalidated session anchor '{session_name}' (node {})",
253                n.id
254            );
255        }
256        self.snapshots.remove(session_name);
257        let dependents: Vec<String> = self
258            .defs
259            .values()
260            .filter(|d| self.depends_on(d, session_name))
261            .map(|d| d.name.clone())
262            .collect();
263        for dep in dependents {
264            self.locks.remove(dep.as_str());
265            if let Some(n) = self.nodes.remove(&dep) {
266                log::debug!(
267                    "invalidated dependent anchor '{dep}' (node {}) due to session '{session_name}'",
268                    n.id
269                );
270            }
271            self.snapshots.remove(&dep);
272        }
273    }
274
275    /// Unmount anchors by name, removing their definition, cached handle, and
276    /// snapshot. The symmetric inverse of [`mount`](Self::mount): after this
277    /// call the names are completely unknown to the registry.
278    ///
279    /// Root and Session anchors are globally shared and cannot be unmounted
280    /// this way; a warning is logged and the name is skipped.
281    ///
282    /// Any Tab anchor whose tab was opened by the workflow (`created=true`) is
283    /// closed via `desktop.browser().close_tab()` before its handle is removed.
284    pub fn unmount(&mut self, names: &[&str], desktop: &D) {
285        for name in names {
286            // Guard: Root/Session/Browser anchors are globally shared — refuse to unmount.
287            if let Some(def) = self.defs.get(*name) {
288                if matches!(def.tier, Tier::Root | Tier::Session | Tier::Browser) {
289                    log::debug!("unmount: ignoring Root/Session/Browser anchor '{name}'");
290                    continue;
291                }
292            }
293            let key = self.prefixed(name);
294            if let Some(handle) = self.tab_handles.remove(&key) {
295                handle.close_if_created(desktop.browser());
296                log::debug!("unmount: removed tab handle for '{name}'");
297            }
298            self.defs.remove(&key);
299            self.locks.remove(key.as_str());
300            if let Some(n) = self.nodes.remove(&key) {
301                log::debug!("unmounted node {} for anchor '{name}'", n.id);
302            }
303            self.snapshots.remove(&key);
304        }
305    }
306
307    /// Check whether a handle is currently cached and live (no re-query).
308    pub fn is_live(&self, name: &str) -> bool {
309        self.nodes.anchor_is_live(name)
310    }
311
312    /// Find a descendant element matching `selector` within `scope`, using a
313    /// stale-first strategy with partial-tree re-resolution:
314    ///
315    /// 1. **Cache hit, live** → return immediately (1 COM call, no DFS).
316    /// 2. **Cache hit, stale, step-parent live** → re-run the selector's last
317    ///    step from the cached step-parent (narrow search — O(subtree) not
318    ///    O(whole tree)).  Update the cache on success; fall through on failure.
319    /// 3. **Cache hit, stale, step-parent also stale** → clear cache entry,
320    ///    fall through to full DFS.
321    /// 4. **Cache miss** → full `find_one` traversal from the anchor root;
322    ///    cache both the result and its step-parent for next time.
323    ///
324    /// The cache is cleared whenever the scope anchor is re-resolved or unmounted.
325    pub fn find_descendant(
326        &mut self,
327        scope: &str,
328        selector: &SelectorPath,
329        desktop: &D,
330    ) -> Result<Option<D::Elem>, AutomataError> {
331        let key = self.effective_key(scope);
332
333        // Tab scope: delegate to the parent Browser anchor with a document-scoped selector.
334        let tab_handle = self
335            .defs
336            .get(&key)
337            .filter(|d| d.tier == Tier::Tab)
338            .and_then(|_| self.tab_handles.get(&key).cloned());
339        if let Some(handle) = tab_handle {
340            let (parent_browser, doc_sel) = handle.scoped_selector(desktop.browser(), selector)?;
341            return self.find_descendant(&parent_browser, &doc_sel, desktop);
342        }
343
344        // Hard error: scope was never registered. Abort rather than poll forever.
345        if !self.defs.contains_key(&key) {
346            return Err(AutomataError::Internal(format!(
347                "scope '{scope}' is not mounted"
348            )));
349        }
350
351        let sel_key = selector.to_string();
352
353        if let Some(cached) = self.nodes.found(&key, &sel_key) {
354            if is_live(&cached) {
355                return Ok(Some(cached));
356            }
357            // Stale. Try narrow re-resolution from the cached step-parent.
358            if let Some(step_parent) = self.nodes.found_parent(&key, &sel_key) {
359                if is_live(&step_parent) {
360                    if let Some(el) = selector.find_one_from_step_parent(&step_parent) {
361                        // Re-found under the same parent — refresh the cache entry.
362                        self.nodes
363                            .set_found(&key, sel_key, el.clone(), Some(step_parent));
364                        return Ok(Some(el));
365                    }
366                    // Not under the same parent anymore — element moved or gone.
367                    // Clear and fall through to full DFS.
368                    self.nodes.remove_found(&key, &sel_key);
369                    // Fall through to full DFS in case element moved elsewhere.
370                } else {
371                    // Both element and step-parent are stale.
372                    self.nodes.remove_found(&key, &sel_key);
373                    return Ok(None);
374                }
375            } else {
376                // No step-parent stored (root-step match) — element is gone.
377                self.nodes.remove_found(&key, &sel_key);
378                return Ok(None);
379            }
380        }
381
382        // Slow path: full traversal from the anchor root.
383        let root = match self.get(scope, desktop) {
384            Ok(el) => el.clone(),
385            Err(_) => return Ok(None),
386        };
387        let found = selector.find_one_with_parent(&root);
388        if let Some((ref el, ref parent)) = found {
389            self.nodes
390                .set_found(&key, sel_key, el.clone(), parent.clone());
391        }
392        Ok(found.map(|(el, _)| el))
393    }
394
395    // ── Internal resolution ───────────────────────────────────────────────────
396
397    /// Resolve anchor by its storage `key` (which may be prefixed for depth-scoped anchors).
398    fn resolve(&mut self, key: &str, desktop: &D) -> Result<(), AutomataError> {
399        let def = self
400            .defs
401            .get(key)
402            .ok_or_else(|| AutomataError::Internal(format!("anchor '{key}' is not registered")))?
403            .clone();
404
405        // For Browser anchors, call ensure() to start Edge with a CDP debug port before
406        // resolving the UIA window handle. This makes ensure() transparent to the resolve path.
407        if def.tier == Tier::Browser {
408            desktop
409                .browser()
410                .ensure()
411                .map_err(|e| AutomataError::Internal(format!("browser.ensure(): {e}")))?;
412        }
413
414        let found: D::Elem = match def.parent.as_deref() {
415            None => {
416                let windows = desktop.application_windows()?;
417                // After first resolution, locks[key].hwnd holds the exact window handle.
418                // Re-resolution is constrained to that HWND so the anchor cannot drift to
419                // a different window of the same process (same PID but different HWND).
420                if let Some(meta) = self.locks.get(key) {
421                    let locked_hwnd = meta.hwnd;
422                    windows
423                        .into_iter()
424                        .find(|w| w.hwnd() == locked_hwnd)
425                        .ok_or_else(|| {
426                            let hwnd_str = locked_hwnd
427                                .map(|h| format!("0x{h:X}"))
428                                .unwrap_or_else(|| "unknown".into());
429                            AutomataError::Internal(format!(
430                                "anchor '{key}' window (hwnd={hwnd_str}) is no longer present"
431                            ))
432                        })?
433                } else {
434                    // First resolution: filter by user-supplied pid/process/selector,
435                    // then additionally by launch_context strategy if present.
436                    let effective_pid = def.pid;
437                    let proc_filter = def.process_name.as_deref().map(|s| s.to_lowercase());
438                    let mut candidates: Vec<_> = windows
439                        .into_iter()
440                        .filter(|w| {
441                            effective_pid
442                                .map_or(true, |pid| w.process_id().map_or(false, |p| p == pid))
443                        })
444                        .filter(|w| {
445                            proc_filter.as_deref().map_or(true, |pf| {
446                                w.process_name()
447                                    .map(|n| n.to_lowercase() == pf)
448                                    .unwrap_or(false)
449                            })
450                        })
451                        .filter(|w| {
452                            // Apply launch_context filter only when the anchor targets the
453                            // same process as the launched exe (or has no process filter at
454                            // all). Anchors that explicitly target a different process are
455                            // left unaffected so multi-root-anchor workflows work correctly.
456                            let ctx_applies = match (&self.launch_context, &proc_filter) {
457                                (Some(ctx), Some(pf)) => *pf == ctx.process_name,
458                                (Some(_), None) => true,
459                                (None, _) => false,
460                            };
461                            if !ctx_applies {
462                                return true;
463                            }
464                            match &self.launch_context {
465                                Some(LaunchContext {
466                                    wait: LaunchWait::NewPid,
467                                    pid,
468                                    ..
469                                }) => w.process_id().map_or(false, |p| p == *pid),
470                                Some(LaunchContext {
471                                    wait: LaunchWait::NewWindow,
472                                    pre_hwnds,
473                                    ..
474                                }) => w.hwnd().map_or(false, |h| !pre_hwnds.contains(&h)),
475                                _ => true, // MatchAny or no launch
476                            }
477                        })
478                        .filter(|w| def.selector.matches(w))
479                        .collect();
480
481                    // Sort candidates by Z-order (topmost window first) so that when
482                    // a process has multiple windows the one currently on top is preferred,
483                    // even if none of them are in the foreground.
484                    if candidates.len() > 1 {
485                        let z_order = desktop.hwnd_z_order();
486                        if !z_order.is_empty() {
487                            let rank = |hwnd: u64| -> usize {
488                                z_order
489                                    .iter()
490                                    .position(|h| *h == hwnd)
491                                    .unwrap_or(usize::MAX)
492                            };
493                            candidates.sort_by_key(|w| w.hwnd().map_or(usize::MAX, rank));
494                        }
495                    }
496
497                    candidates.into_iter().next().ok_or_else(|| {
498                        AutomataError::Internal(format!(
499                            "anchor '{key}' not found in application windows \
500                                 (selector: {}, pid: {:?}, process: {:?})",
501                            def.selector, effective_pid, def.process_name
502                        ))
503                    })?
504                }
505            }
506            Some(raw_parent_name) => {
507                let raw_parent_name = raw_parent_name.to_string();
508                // Resolve parent using get(), which applies effective_key internally.
509                let parent = self
510                    .get(&raw_parent_name, desktop)
511                    .map_err(|_| {
512                        AutomataError::Internal(format!(
513                            "parent anchor '{raw_parent_name}' unavailable while resolving '{key}'"
514                        ))
515                    })?
516                    .clone();
517
518                def.selector.find_one(&parent).ok_or_else(|| {
519                    AutomataError::Internal(format!(
520                        "anchor '{key}' not found under '{raw_parent_name}' \
521                         (selector: {})",
522                        def.selector
523                    ))
524                })?
525            }
526        };
527
528        // Browser anchors must own the foreground so keyboard input reaches them.
529        if def.tier == Tier::Browser {
530            if let Err(e) = found.activate_window() {
531                log::warn!("Browser anchor '{key}': activate_window failed: {e}");
532            }
533        }
534
535        // Lock the anchor to the resolved window. HWND prevents same-PID drift;
536        // PID is kept for anchor_pid() callers.
537        if let Ok(pid) = found.process_id() {
538            self.locks.insert(
539                AnchorName::new(key),
540                AnchorMeta {
541                    hwnd: found.hwnd(),
542                    pid,
543                },
544            );
545        }
546
547        // Capture the initial snapshot so the first sync() call has a baseline.
548        // Render the trace log from the snapshot — no second COM traversal needed.
549        let snap = SnapNode::capture(&found, SNAP_DEPTH);
550        log::debug!("resolved anchor '{key}':\n{}", snap.format_tree(0));
551        self.snapshots.insert(key.to_string(), snap);
552        // NodeCache::insert also clears found-cache for this scope.
553        let id = self.nodes.insert(key.to_string(), found);
554        log::debug!("cached node {id} for anchor '{key}'");
555
556        Ok(())
557    }
558
559    /// Snapshot the live subtree of `name`, diff against the previous snapshot,
560    /// emit each change to the tracer, and **return** the change lines.
561    ///
562    /// The returned `Vec` is empty when nothing changed. Each line has the form:
563    /// ```text
564    /// dom: <scope>: ADDED [role "name"]
565    /// dom: <scope> > [role "name"]: REMOVED [child-role "child"]
566    /// dom: <scope>: name "old" → "new"
567    /// ```
568    ///
569    /// Primarily used by the executor's poll loop, but also directly testable.
570    pub fn sync_changes(&mut self, name: &str, desktop: &D) -> Vec<String> {
571        let key = self.effective_key(name);
572        let el = match self.get(name, desktop) {
573            Ok(e) => e.clone(),
574            Err(_) => return vec![],
575        };
576        let new_snap = SnapNode::capture(&el, SNAP_DEPTH);
577        let mut changes = Vec::new();
578        if let Some(old_snap) = self.snapshots.get(&key) {
579            old_snap.diff_into(&new_snap, name, &mut changes);
580        }
581        self.snapshots.insert(key, new_snap);
582        for line in &changes {
583            log::debug!("{line}");
584        }
585        changes
586    }
587
588    /// Like [`sync_changes`] but discards the return value.
589    /// Convenience for the executor's poll loop.
590    pub fn sync(&mut self, name: &str, desktop: &D) {
591        self.sync_changes(name, desktop);
592    }
593
594    /// Returns the effective PID for a registered anchor.
595    /// Prefers the locked PID (captured on first resolution) over the statically
596    /// declared PID, since the locked value is always the more specific one.
597    pub fn anchor_pid(&self, name: &str) -> Option<u32> {
598        let key = self.effective_key(name);
599        self.locks
600            .get(key.as_str())
601            .map(|m| m.pid)
602            .or_else(|| self.defs.get(&key)?.pid)
603    }
604
605    /// Returns the locked HWND for a registered anchor, if one was captured on first resolution.
606    pub fn anchor_hwnd(&self, name: &str) -> Option<u64> {
607        let key = self.effective_key(name);
608        self.locks.get(key.as_str()).and_then(|m| m.hwnd)
609    }
610
611    /// Remove and return all tab handles mounted at exactly `depth`.
612    fn drain_tabs_at_depth(&mut self, depth: usize) -> Vec<TabHandle> {
613        let keys: Vec<String> = self
614            .tab_handles
615            .iter()
616            .filter(|(_, h)| h.depth == depth)
617            .map(|(k, _)| k.clone())
618            .collect();
619        keys.into_iter()
620            .filter_map(|k| self.tab_handles.remove(&k))
621            .collect()
622    }
623
624    /// Return a reference to the tab handle for `name`, if present.
625    pub fn tab_handle(&self, name: &str) -> Option<&TabHandle> {
626        let key = self.effective_key(name);
627        self.tab_handles.get(&key)
628    }
629
630    /// Remove all anchors stored at exactly `depth` (keys prefixed with exactly
631    /// `":".repeat(depth)` followed by a non-colon character).
632    /// Acts as a safety net for anything not explicitly unmounted by the subflow.
633    /// Any created Tab anchors at this depth are closed via `desktop.browser().close_tab()`.
634    pub fn cleanup_depth(&mut self, depth: usize, desktop: &D) {
635        // Close and drain any tab handles at this depth.
636        let tabs = self.drain_tabs_at_depth(depth);
637        for handle in tabs {
638            handle.close_if_created(desktop.browser());
639            log::debug!(
640                "cleanup depth {depth}: removed tab handle for tab_id='{}'",
641                handle.tab_id
642            );
643        }
644
645        let prefix = ":".repeat(depth);
646        // Remove defs introduced at this depth — covers both depth-prefixed
647        // stable/ephemeral anchors AND unprefixed root anchors that were
648        // registered by a subflow (not inherited from a parent).
649        let def_keys: Vec<String> = self
650            .defs
651            .iter()
652            .filter(|(_, def)| def.mount_depth == depth)
653            .map(|(k, _)| k.clone())
654            .collect();
655        // Also sweep orphan node-cache entries (Capture ephemerals without defs)
656        // that carry the depth prefix but no def entry.
657        let node_keys: Vec<String> = self
658            .nodes
659            .anchor_names()
660            .into_iter()
661            .filter(|k| {
662                k.starts_with(&prefix)
663                    && k.len() > prefix.len()
664                    && !k[prefix.len()..].starts_with(':')
665            })
666            .collect();
667        for key in def_keys {
668            self.defs.remove(&key);
669            self.locks.remove(key.as_str());
670            if let Some(n) = self.nodes.remove(&key) {
671                log::debug!(
672                    "cleanup depth {depth}: removed node {} for key '{key}'",
673                    n.id
674                );
675            }
676            self.snapshots.remove(&key);
677        }
678        // Also remove orphan nodes (Capture ephemerals without defs).
679        for key in node_keys {
680            if self.nodes.remove(&key).is_some() {
681                log::debug!("cleanup depth {depth}: removed orphan node for key '{key}'");
682            }
683        }
684    }
685
686    /// Returns true if `def` is a (transitive) child of `session_name`.
687    fn depends_on(&self, def: &AnchorDef, session_name: &str) -> bool {
688        let mut current = def.parent.as_deref();
689        while let Some(p) = current {
690            if p == session_name {
691                return true;
692            }
693            current = self.defs.get(p).and_then(|d| d.parent.as_deref());
694        }
695        false
696    }
697}
698
699impl<D: Desktop> Default for ShadowDom<D> {
700    fn default() -> Self {
701        Self::new()
702    }
703}