yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Tick / sweep / re-derivation machinery for [`AppModel`] (DESIGN §7.2, §7.3).
//!
//! One frame's work: drain the bridge-delivered dirty roots and route each by
//! kind — a workspace root opens a debounce window, an enumeration root
//! re-enumerates + reconciles the watch set, the yog-state root adopts an
//! external `ui.json`. Then the periodic sweeps (cheap re-enumerate + targeted
//! liveness re-probe; full mark-everything) and, finally, re-derivation of
//! every workspace whose 100 ms window has elapsed. Correctness never rides on
//! an event arriving — the 15 s full sweep bounds staleness (§7.2).

use super::{AppModel, desired_watches, needs_liveness_reprobe};
use crate::binding;
use crate::world::toolgate::{self, ToolchainState};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

impl AppModel {
    /// One frame's re-derivation work (§7.2). Returns whether the snapshot map
    /// changed (a repaint-worthy delta).
    pub fn tick(&mut self) -> bool {
        let delivered = self.dirty.drain();
        self.dispatch_dirty(delivered);
        let sweep = self.schedule.sweep();
        if sweep == super::dirty::Sweep::Full {
            self.full_sweep();
        } else if sweep == super::dirty::Sweep::Cheap {
            self.cheap_sweep();
        }
        let _ = self.ui.flush_if_due();
        let mut changed = false;
        for root in self.schedule.due() {
            changed |= self.rederive(&root);
        }
        changed
    }

    /// Route each dirty root by kind (§7.1): the yog-state root adopts
    /// `ui.json` and re-reads the ops tail; the balls-clones root re-fetches
    /// ball lists (§7.2 fetch cadence); an enumeration root re-enumerates +
    /// reconciles; anything else is a workspace root and opens a debounce window.
    fn dispatch_dirty(&mut self, roots: BTreeSet<PathBuf>) {
        let mut workspaces = Vec::new();
        for root in roots {
            if root == self.roots.yog_state {
                self.adopt_ui();
                self.refresh_ops();
            } else if root == self.roots.balls_clones {
                self.refresh_balls();
            } else if self.is_enum_root(&root) {
                self.reconcile();
            } else {
                workspaces.push(root);
            }
        }
        self.schedule.mark(workspaces);
    }

    /// An enumeration root (§7.1): the flat names root, the lernie workspaces
    /// root, or the replays root — a create/remove there changes the workspace
    /// set.
    fn is_enum_root(&self, root: &Path) -> bool {
        root == self.roots.names()
            || root == self.roots.workspaces()
            || root == self.roots.replays()
    }

    /// Adopt an external `ui.json` change (§4.1, I5): read the file and, unless
    /// it is our own echo (content-hash match), wholesale-adopt it — the
    /// converging seen/pins path both instances share. A missing/unreadable
    /// file is left alone.
    fn adopt_ui(&mut self) {
        if let Ok(bytes) = std::fs::read(self.roots.ui_json())
            && !self.ui.is_echo(&bytes)
        {
            self.ui.adopt(&bytes);
        }
    }

    /// Re-enumerate the workspace set, reconcile the watch set to it, prune
    /// snapshots for vanished workspaces, and mark newly-appeared workspaces for
    /// an initial derive (§7.2 cheap enumeration; §7.3 re-primed-clone rebuild).
    fn reconcile(&mut self) {
        self.workspaces = binding::workspaces(&self.roots.yog_data, &self.roots.lernie_data);
        let desired = desired_watches(&self.roots, &self.workspaces);
        crate::state::lock_watchset(&self.watchset).reconcile(&desired);
        let known: BTreeSet<PathBuf> = self.workspaces.iter().map(|w| w.path.clone()).collect();
        self.trees.retain(|path, _| known.contains(path));
        let mut missing = Vec::new();
        for w in &self.workspaces {
            if !self.trees.contains_key(&w.path) {
                missing.push(w.path.clone());
            }
        }
        self.schedule.mark(missing);
        // The workspace set is a join axis (§3.5): a fresh minted workspace (the
        // start flow's `lernie new`) that lands via a NamesRoot event must re-bind
        // the balls at once, else the just-claimed ball renders claimed-elsewhere
        // until the 15 s sweep. Rebuild the join over the already-fetched balls.
        let cloned: Vec<PathBuf> = self.balls_by_project.keys().cloned().collect();
        self.rebuild_join(&cloned);
    }

    /// The 2 s cheap sweep (§7.2): reconcile, then the *targeted* liveness
    /// re-probe — for each workspace holding a Live/InFlight agent, evict its
    /// agents' cached lock observations (§10 eager refresh, so a silently
    /// released flock is caught) and mark it for re-derivation.
    fn cheap_sweep(&mut self) {
        self.reconcile();
        let mut live: Vec<(PathBuf, Vec<String>)> = Vec::new();
        for (path, tree) in &self.trees {
            if needs_liveness_reprobe(tree) {
                let ids = tree.agents.iter().map(|a| a.agent_id.clone()).collect();
                live.push((path.clone(), ids));
            }
        }
        for (path, ids) in live {
            self.probes.invalidate_liveness(&path, &ids);
            self.schedule.mark([path]);
        }
    }

    /// The 15 s full sweep (§7.2): reconcile, re-fetch every project's balls and
    /// the ops tail (the fetch cadence's floor), and mark every workspace dirty,
    /// so a dropped inotify event costs ≤15 s of latency, never divergence.
    fn full_sweep(&mut self) {
        self.reconcile();
        self.refresh_balls();
        self.refresh_ops();
        let all: Vec<PathBuf> = self.workspaces.iter().map(|w| w.path.clone()).collect();
        self.schedule.mark(all);
    }

    /// Re-derive one workspace through the held probe stack, replacing its
    /// snapshot iff it actually changed (`GitTree: PartialEq` suppresses no-op
    /// repaints, §7.2). A read failure keeps the last good snapshot.
    fn rederive(&mut self, workspace: &Path) -> bool {
        let Ok(tree) = self.probes.derive(workspace) else {
            return false;
        };
        if self.trees.get(workspace) == Some(&tree) {
            return false;
        }
        self.trees.insert(workspace.to_path_buf(), tree);
        true
    }

    /// The classified host-tool state (§16.6 W5) — the read-only toolchain
    /// pane's source and the mutating verbs' gate. `pub(crate)`: a borrow, kept
    /// off the public surface (rules/no-pub-borrow-return.yml).
    pub(crate) fn toolchain(&self) -> &ToolchainState {
        &self.toolchain
    }

    /// Re-probe the host toolchain through the injected seam (§16.6 W5, the
    /// pane's on-demand refresh). The read path is never gated on it (§16.4).
    pub fn refresh_toolchain(&mut self) {
        self.toolchain = toolgate::probe(self.probe.as_ref());
    }
}