nornir 0.5.0

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
//! πŸ” **Shared per-repo pane empty-state + auto-refresh helper** (LAW 2 + the
//! sticky-`loaded` fix), applied identically across every per-repo viz pane
//! (πŸ› arch / πŸ•Έ callgraph / πŸ”— depgraph / 🧠 knowledge / πŸš‡ metro / πŸ§ͺ test).
//!
//! ## The systemic bug this closes
//! Every per-repo pane carried its OWN `loaded: bool` latch β€” "true once a load
//! has been attempted (so we don't reload every frame)". It latched after the
//! FIRST fetch, *even a zero-row one*, and never re-queried until a workspace
//! re-scope dropped the cache. Two failures fell out of that:
//!
//! 1. **No LAW-2 empty-state.** A zero-row warehouse query rendered an empty
//!    board β€” nothing drawn β€” conflating "no data yet" with "nothing to draw".
//! 2. **No auto-refresh.** A background `workspace_populate` could write the
//!    repo's rows a second after the first (empty) fetch, but the pane stayed
//!    blank until the user clicked ↻ or switched workspaces.
//!
//! ## The one shared mechanism
//! * [`should_refetch`] β€” replaces the boolean latch. While the pane is EMPTY it
//!   re-fetches when a populate job for this repo is active OR a throttled
//!   interval ([`REFETCH_EVERY`]) has elapsed; once it has data it latches like
//!   before (no per-frame churn).
//! * [`RefetchGate`] β€” bundles `loaded` + `last_fetch_at` so a pane adopts the
//!   throttle by storing one field and calling [`RefetchGate::should_fetch`].
//! * [`classify_empty`] / [`EmptyState`] β€” picks the LAW-2 message: if an active
//!   job targets this repo β†’ **"⏳ in route for populate…"**, else β†’ **"not
//!   scanned yet β€” run populate / deep-scan"**. The pane renders that instead of
//!   a silent blank board.
//! * [`populate_active_for`] β€” answers "is a populate/clone/scan/arch/etc. job
//!   active for this repo right now?" from the process-global jobs snapshot the
//!   app's jobs poller publishes via [`publish_active_jobs`] (so panes need NO
//!   per-pane `Viz.Jobs` plumbing β€” one poll feeds all of them).

use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};

use crate::jobs::{kind, JobRecord};

/// How often an EMPTY per-repo pane re-queries the warehouse on its own (the
/// throttle floor). Short enough that a background populate's rows appear within
/// a couple of seconds without a click; long enough that an empty pane never
/// re-scans every frame. Mirrors `LIVE_RELOAD_EVERY` in `arch_tab`.
pub const REFETCH_EVERY: Duration = Duration::from_millis(2000);

// ───────────────────────────────────────────────────────────────────────────
// Process-global active-jobs snapshot.
//
// The app already polls `Viz.Jobs` (fat: `jobs.redb`) once every ~1.5s in the
// 🧬 nornir pane. Rather than give every per-repo pane its own RPC, that one
// poller PUBLISHES the records here and the panes READ them β€” one source of
// truth, zero extra round-trips. Cheap clone-free reads behind a Mutex.
// ───────────────────────────────────────────────────────────────────────────

fn jobs_cell() -> &'static Mutex<Vec<JobRecord>> {
    static JOBS: OnceLock<Mutex<Vec<JobRecord>>> = OnceLock::new();
    JOBS.get_or_init(|| Mutex::new(Vec::new()))
}

/// Publish the latest jobs snapshot (called by the app's jobs poller every cycle).
/// Replaces the previous snapshot wholesale β€” the poller always ships the full
/// newest-first list, so the panes see exactly what the Jobs panel sees.
pub fn publish_active_jobs(records: &[JobRecord]) {
    if let Ok(mut g) = jobs_cell().lock() {
        *g = records.to_vec();
    }
}

/// Job kinds whose ACTIVE (non-terminal) presence means a repo's rows are
/// "in route" β€” a populate/clone or any scan that will write the warehouse the
/// per-repo panes read. A pane that is empty *because one of these is running*
/// must say "in route", not "not scanned".
fn is_populating_kind(k: &str) -> bool {
    matches!(
        k,
        kind::WORKSPACE_POPULATE
            | kind::WORKSPACE_CLONE
            | kind::WORKSPACE_FETCH
            | kind::WORKSPACE_REPUBLISH
            | kind::KNOWLEDGE_SCAN
            | kind::DEEPSCAN
            | kind::SYMBOL_SCAN
            | kind::SNAPSHOT
            | kind::ARCH_GENERATE
            | kind::INDEX_BUILD
    )
}

/// Does a job in `records` "target this repo" in `workspace`? A job's `target`
/// is the repo/member name (or the workspace name for whole-workspace ops like
/// populate). We match when the job is for this workspace AND (a) `repo` is
/// unknown/empty (any populating job for the workspace counts), or (b) the job's
/// `target` names this repo, or (c) the job is a whole-workspace populate/fetch
/// (its `target` is the workspace, so it covers every member).
fn job_targets_repo(r: &JobRecord, workspace: &str, repo: &str) -> bool {
    if !workspace.is_empty() && r.workspace != workspace {
        return false;
    }
    if repo.is_empty() {
        return true;
    }
    // A per-member job names the repo directly …
    if r.target == repo {
        return true;
    }
    // … and a whole-workspace umbrella op (its target is the workspace itself,
    // or it is the populate/fetch parent) covers every member of the workspace.
    matches!(
        r.kind.as_str(),
        kind::WORKSPACE_POPULATE | kind::WORKSPACE_FETCH | kind::WORKSPACE_REPUBLISH
    ) || r.target == workspace
}

/// Is a populate/clone/scan/arch job ACTIVE (queued or running, i.e. non-terminal)
/// for `repo` in `workspace` right now? `repo` may be empty when a pane has not
/// yet picked a member (then any populating job for the workspace counts). Reads
/// the snapshot [`publish_active_jobs`] keeps fresh β€” no RPC.
pub fn populate_active_for(workspace: &str, repo: &str) -> bool {
    let Ok(g) = jobs_cell().lock() else { return false };
    g.iter().any(|r| {
        !r.is_terminal() && is_populating_kind(&r.kind) && job_targets_repo(r, workspace, repo)
    })
}

/// Test seam: clear the global jobs snapshot (so a unit test starts from a known
/// empty state and isn't polluted by an earlier test in the same process).
#[doc(hidden)]
pub fn clear_active_jobs_for_test() {
    if let Ok(mut g) = jobs_cell().lock() {
        g.clear();
    }
}

// ───────────────────────────────────────────────────────────────────────────
// The auto-refresh throttle (replaces the sticky `loaded` latch).
// ───────────────────────────────────────────────────────────────────────────

/// Decide whether a per-repo pane should (re)fetch its warehouse data THIS frame.
///
/// * `loaded` β€” has a fetch been attempted at all yet?
/// * `last_fetch_at` β€” when the last fetch ran (`None` β‡’ never).
/// * `is_empty` β€” did the most recent fetch yield ZERO rows (the empty board)?
/// * `populate_active` β€” is a populate/scan job for this repo active right now
///   (from [`populate_active_for`])?
///
/// Contract:
/// * **Never fetched** β†’ fetch (`!loaded`).
/// * **Has data** (`!is_empty`) β†’ do NOT auto-refetch (latch, exactly the old
///   behaviour β€” no per-frame churn once the board is populated).
/// * **Empty + a populate job is active** β†’ refetch eagerly (the rows are
///   landing; don't wait the throttle out).
/// * **Empty + no job** β†’ refetch once [`REFETCH_EVERY`] has elapsed since the
///   last fetch (a slow throttle so an empty pane self-heals when the warehouse
///   gains the repo's rows by any means, without a click).
pub fn should_refetch(
    loaded: bool,
    last_fetch_at: Option<Instant>,
    is_empty: bool,
    populate_active: bool,
) -> bool {
    if !loaded {
        return true;
    }
    if !is_empty {
        // Populated β†’ latch. (The pane's own ↻ reload / workspace switch still
        // drops the cache explicitly; this helper just stops blank-state sticking.)
        return false;
    }
    if populate_active {
        return true;
    }
    match last_fetch_at {
        Some(t) => t.elapsed() >= REFETCH_EVERY,
        None => true,
    }
}

/// A tiny adopt-me bundle of the throttle's two pieces of state, so a pane can
/// replace its `loaded: bool` with one field and call [`RefetchGate::should_fetch`].
/// Keeps the per-pane diff minimal and the behaviour identical everywhere.
#[derive(Debug, Clone, Copy)]
pub struct RefetchGate {
    /// `true` once a fetch has been attempted (the old `loaded` flag's role).
    pub loaded: bool,
    /// When the last fetch ran (`None` β‡’ never), for the throttle.
    pub last_fetch_at: Option<Instant>,
}

impl Default for RefetchGate {
    fn default() -> Self {
        Self { loaded: false, last_fetch_at: None }
    }
}

impl RefetchGate {
    /// Should the pane fetch this frame? See [`should_refetch`].
    pub fn should_fetch(&self, is_empty: bool, populate_active: bool) -> bool {
        should_refetch(self.loaded, self.last_fetch_at, is_empty, populate_active)
    }

    /// Record that a fetch just ran (mark loaded + stamp the throttle clock).
    /// Call this immediately after a fetch attempt, success or empty.
    pub fn mark_fetched(&mut self) {
        self.loaded = true;
        self.last_fetch_at = Some(Instant::now());
    }

    /// Drop the latch (a ↻ reload / workspace switch) so the next frame refetches.
    pub fn reset(&mut self) {
        self.loaded = false;
        self.last_fetch_at = None;
    }
}

// ───────────────────────────────────────────────────────────────────────────
// The LAW-2 empty-state classifier.
// ───────────────────────────────────────────────────────────────────────────

/// Why a per-repo pane is empty β€” drives the message it renders INSTEAD of a
/// silent blank board (LAW 2 + its POPULATE-DIAGNOSTIC corollary).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmptyState {
    /// The pane HAS data β€” not empty (render the board).
    Populated,
    /// Empty AND a populate/clone/scan job for this repo is active right now: the
    /// rows are on their way. "⏳ in route for populate…".
    InRoute,
    /// Empty and no job is acting on this repo: nothing has produced the data yet.
    /// "not scanned yet β€” run populate / deep-scan".
    NotScanned,
}

impl EmptyState {
    /// The stable `state_json` tag (so a headless drive asserts WHICH empty-state
    /// the pane is in, not just "empty").
    pub fn id(self) -> &'static str {
        match self {
            EmptyState::Populated => "populated",
            EmptyState::InRoute => "in_route",
            EmptyState::NotScanned => "not_scanned",
        }
    }

    /// The user-facing message for the empty board (`None` when populated).
    /// The exact strings the panes render + the matrix asserts on.
    pub fn message(self, repo: &str) -> Option<String> {
        match self {
            EmptyState::Populated => None,
            EmptyState::InRoute => Some(if repo.is_empty() {
                "⏳ in route for populate…".to_string()
            } else {
                format!("⏳ in route for populate… ({repo})")
            }),
            EmptyState::NotScanned => Some(if repo.is_empty() {
                "not scanned yet β€” run populate / deep-scan".to_string()
            } else {
                format!("not scanned yet β€” run populate / deep-scan for {repo}")
            }),
        }
    }

    /// `true` for the two genuinely-empty variants (not [`EmptyState::Populated`]).
    pub fn is_empty(self) -> bool {
        !matches!(self, EmptyState::Populated)
    }
}

/// Classify a per-repo pane's empty-state. `is_empty` is the pane's own
/// zero-rows verdict; `workspace`/`repo` name what it is showing (look up the
/// active jobs internally). This is the single call every pane makes to turn a
/// blank board into a reasoned message.
pub fn classify_empty(is_empty: bool, workspace: &str, repo: &str) -> EmptyState {
    if !is_empty {
        return EmptyState::Populated;
    }
    if populate_active_for(workspace, repo) {
        EmptyState::InRoute
    } else {
        EmptyState::NotScanned
    }
}

/// Render the LAW-2 empty-state message into an egui pane (centred, never a hard
/// error). Returns the [`EmptyState`] it drew so the caller can fold it into
/// `state_json`. No-op render when populated (returns [`EmptyState::Populated`]).
#[cfg(feature = "viz")]
pub fn render_empty(
    ui: &mut eframe::egui::Ui,
    is_empty: bool,
    workspace: &str,
    repo: &str,
    text_color: eframe::egui::Color32,
) -> EmptyState {
    let st = classify_empty(is_empty, workspace, repo);
    if let Some(msg) = st.message(repo) {
        ui.add_space(16.0);
        ui.vertical_centered(|ui| {
            ui.colored_label(text_color, msg);
        });
    }
    st
}

#[cfg(test)]
mod tests {
    use super::*;

    fn rec(kind: &str, target: &str, workspace: &str, status: &str) -> JobRecord {
        JobRecord {
            job_id: format!("{kind}-{target}-{status}"),
            kind: kind.to_string(),
            target: target.to_string(),
            workspace: workspace.to_string(),
            status: status.to_string(),
            ts_start_micros: 0,
            ts_end_micros: None,
            elapsed_ms: None,
            detail_json: String::new(),
            result_ref: String::new(),
            parent_id: None,
        }
    }

    #[test]
    fn never_fetched_always_fetches() {
        assert!(should_refetch(false, None, false, false));
        assert!(should_refetch(false, Some(Instant::now()), false, true));
    }

    #[test]
    fn populated_latches_no_refetch() {
        // Has data β†’ never auto-refetch, even with a job active.
        assert!(!should_refetch(true, Some(Instant::now()), false, true));
        assert!(!should_refetch(true, Some(Instant::now()), false, false));
    }

    #[test]
    fn empty_with_active_job_refetches_eagerly() {
        // Just fetched (clock barely elapsed) but a populate is active β†’ refetch.
        assert!(should_refetch(true, Some(Instant::now()), true, true));
    }

    #[test]
    fn empty_without_job_throttles() {
        // Just fetched, empty, no job β†’ wait the throttle out (no refetch yet).
        assert!(!should_refetch(true, Some(Instant::now()), true, false));
        // Throttle elapsed β†’ refetch.
        let old = Instant::now() - (REFETCH_EVERY + Duration::from_millis(50));
        assert!(should_refetch(true, Some(old), true, false));
    }

    #[test]
    fn gate_lifecycle() {
        let mut g = RefetchGate::default();
        assert!(g.should_fetch(false, false), "fresh gate fetches");
        g.mark_fetched();
        assert!(!g.should_fetch(false, false), "populated gate latches");
        // Empty + active job β†’ refetch even right after.
        assert!(g.should_fetch(true, true));
        g.reset();
        assert!(g.should_fetch(true, false), "reset gate fetches");
    }

    #[test]
    fn classify_empty_picks_in_route_when_job_active() {
        clear_active_jobs_for_test();
        publish_active_jobs(&[rec(kind::WORKSPACE_POPULATE, "ws", "ws", "running")]);
        // A whole-workspace populate covers every member β†’ in route for repo `a`.
        assert_eq!(classify_empty(true, "ws", "a"), EmptyState::InRoute);
        // Populated short-circuits regardless of jobs.
        assert_eq!(classify_empty(false, "ws", "a"), EmptyState::Populated);
        clear_active_jobs_for_test();
    }

    #[test]
    fn classify_empty_not_scanned_when_no_job() {
        clear_active_jobs_for_test();
        // No jobs at all β†’ not scanned.
        assert_eq!(classify_empty(true, "ws", "a"), EmptyState::NotScanned);
        // A job for a DIFFERENT workspace must not count.
        publish_active_jobs(&[rec(kind::KNOWLEDGE_SCAN, "a", "other-ws", "running")]);
        assert_eq!(classify_empty(true, "ws", "a"), EmptyState::NotScanned);
        clear_active_jobs_for_test();
    }

    #[test]
    fn classify_empty_terminal_job_is_not_in_route() {
        clear_active_jobs_for_test();
        // A DONE populate is not active β†’ not scanned, not in route.
        publish_active_jobs(&[rec(kind::WORKSPACE_POPULATE, "ws", "ws", "done")]);
        assert_eq!(classify_empty(true, "ws", "a"), EmptyState::NotScanned);
        clear_active_jobs_for_test();
    }

    #[test]
    fn per_member_scan_targets_that_repo_only() {
        clear_active_jobs_for_test();
        // A per-member knowledge scan of repo `a` β†’ repo `a` is in route, `b` not.
        publish_active_jobs(&[rec(kind::KNOWLEDGE_SCAN, "a", "ws", "running")]);
        assert_eq!(classify_empty(true, "ws", "a"), EmptyState::InRoute);
        assert_eq!(classify_empty(true, "ws", "b"), EmptyState::NotScanned);
        clear_active_jobs_for_test();
    }

    #[test]
    fn empty_state_messages_are_the_law2_strings() {
        assert!(EmptyState::InRoute.message("nornir").unwrap().contains("in route for populate"));
        assert!(EmptyState::NotScanned
            .message("nornir")
            .unwrap()
            .contains("not scanned yet"));
        assert!(EmptyState::Populated.message("nornir").is_none());
    }
}