Skip to main content

majordomus_cli/reasoning/
availability.rs

1//! Which advisors can be asked right now, and why each one that cannot, cannot.
2//!
3//! Three inputs, all cheap and none over the network (ADR 0032):
4//!
5//! * **presence** — an executable on `PATH`, a credential variable *set* (its value is
6//!   never read). Presence is what the crate can know; it says "installed/configured",
7//!   not "answering", and the status says which it measured.
8//! * **the reasoning mode** — `offline` admits only advisors that stay on the machine,
9//!   `ci` admits none, the rest admit all; an explicit disable list removes any.
10//! * **recorded outcomes** — every consultation a session recorded carries its status.
11//!   A run of transient failures opens the advisor's circuit for a cooldown, a rate limit
12//!   for its own, an authentication failure for longer; once the cooldown passes the
13//!   advisor is available again and marked `recovering`. The circuit is therefore a pure
14//!   function of the records and the clock: deterministic, replayable, and never a
15//!   permanent failure nobody configured.
16//!
17//! An empty result is an ordinary answer. A session with no advisor still reasons; it
18//! reasons locally, and says so.
19//!
20//! # Example
21//!
22//! One advisor through its lifecycle: present, then two timeouts open its circuit, then
23//! the cooldown passes and it is available again, marked `recovering`.
24//!
25//! ```
26//! use majordomus_cli::reasoning::availability::{
27//!     derive_availability, resolve_mode, AdvisorState, AdvisorStatus, Inputs, Outcome,
28//!     Presence, FAILURE_COOLDOWN,
29//! };
30//! use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, References};
31//! use majordomus_cli::reasoning::record::ConsultationStatus;
32//!
33//! // Presence is the seam a test fakes: this "machine" has the executable `a`.
34//! struct Installed;
35//! impl Presence for Installed {
36//!     fn executable(&self, name: &str) -> bool { name == "a" }
37//!     fn variable(&self, _: &str) -> bool { false }
38//! }
39//! let catalogue: AdvisorCatalogue = majordomus_cli::metadata::yaml::parse_into(
40//!     "version: 1\ncapabilities:\n  - id: code_review\n    description: x\nadvisors:\n  - id: a\n    title: A\n    transport: cli\n    adapter: a-cli\n    executable: a\n    capabilities: [code_review]\n",
41//! ).unwrap();
42//! let refs = References::default();
43//! let mode = resolve_mode(None, false, None);
44//! let at = |now: i64, history: &[Outcome]| -> AdvisorState {
45//!     derive_availability(&catalogue, &Inputs {
46//!         refs: &refs, presence: &Installed, mode: mode.mode, disabled: &[],
47//!         history, peers: &[], now, cooldown: None,
48//!     })
49//!     .remove(0)
50//! };
51//!
52//! assert_eq!(at(0, &[]).status, AdvisorStatus::Available);
53//!
54//! let timeouts: Vec<Outcome> = (0..2)
55//!     .map(|i| Outcome {
56//!         id: format!("consultation-{i}"),
57//!         advisor: "a".into(),
58//!         status: ConsultationStatus::Timeout,
59//!         at: 100 + i,
60//!         retry_after: None,
61//!     })
62//!     .collect();
63//! let open = at(102, &timeouts);
64//! assert_eq!(open.status, AdvisorStatus::TemporarilyFailed);
65//! assert_eq!(open.reason, "consecutive_failures");
66//!
67//! let later = at(101 + FAILURE_COOLDOWN, &timeouts);
68//! assert_eq!(later.status, AdvisorStatus::Available);
69//! assert!(later.recovering);
70//! ```
71
72use schemars::JsonSchema;
73use serde::{Deserialize, Serialize};
74
75use super::catalogue::{AdvisorCatalogue, AdvisorTransport, References};
76use super::record::ConsultationStatus;
77
78/// How much independent review a session may use, and where from. The policy reads it
79/// for the review budget; availability reads it to leave advisors out (`ci` all of them,
80/// `offline` every one that leaves the machine). `standard` is the default.
81///
82/// ```
83/// use majordomus_cli::reasoning::availability::ReasoningMode;
84///
85/// assert_eq!(ReasoningMode::default(), ReasoningMode::Standard);
86/// assert_eq!(serde_json::to_value(ReasoningMode::Offline).unwrap(), "offline");
87/// let m: ReasoningMode = serde_json::from_str("\"strict\"").unwrap();
88/// assert_eq!(m, ReasoningMode::Strict);
89/// ```
90#[derive(
91    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
92)]
93#[serde(rename_all = "snake_case")]
94pub enum ReasoningMode {
95    /// No network: only advisors that stay on this machine.
96    Offline,
97    /// Deterministic: no advisor at all, whatever is installed.
98    Ci,
99    /// Review only where the blast radius is high.
100    Fast,
101    /// Review where uncertainty is material.
102    #[default]
103    Standard,
104    /// Review wider, and complementary review where the stakes are highest.
105    Strict,
106}
107
108impl ReasoningMode {
109    /// The mode a word names, ignoring surrounding whitespace. `None` for a word that
110    /// names no mode: a caller skips it rather than guessing at one.
111    ///
112    /// ```
113    /// use majordomus_cli::reasoning::availability::ReasoningMode;
114    ///
115    /// assert_eq!(ReasoningMode::parse(" ci "), Some(ReasoningMode::Ci));
116    /// assert_eq!(ReasoningMode::parse("loud"), None);
117    /// ```
118    pub fn parse(word: &str) -> Option<Self> {
119        Some(match word.trim() {
120            "offline" => Self::Offline,
121            "ci" => Self::Ci,
122            "fast" => Self::Fast,
123            "standard" => Self::Standard,
124            "strict" => Self::Strict,
125            _ => return None,
126        })
127    }
128
129    /// The word that names the mode, as configuration, profiles and plans write it: the
130    /// inverse of [`ReasoningMode::parse`].
131    ///
132    /// ```
133    /// use majordomus_cli::reasoning::availability::ReasoningMode;
134    ///
135    /// for word in ["offline", "ci", "fast", "standard", "strict"] {
136    ///     assert_eq!(ReasoningMode::parse(word).unwrap().as_str(), word);
137    /// }
138    /// ```
139    pub fn as_str(self) -> &'static str {
140        match self {
141            Self::Offline => "offline",
142            Self::Ci => "ci",
143            Self::Fast => "fast",
144            Self::Standard => "standard",
145            Self::Strict => "strict",
146        }
147    }
148}
149
150/// The mode in force and what decided it, as [`resolve_mode`] returns it. A plan and a
151/// report carry the source beside the mode, so a reader learns why the mode is what it is.
152///
153/// ```
154/// use majordomus_cli::reasoning::availability::{resolve_mode, ModeResolution, ReasoningMode};
155///
156/// let r: ModeResolution = resolve_mode(None, true, None);
157/// assert_eq!(r, ModeResolution { mode: ReasoningMode::Ci, source: "CI".into() });
158/// ```
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
160pub struct ModeResolution {
161    /// The mode.
162    pub mode: ReasoningMode,
163    /// What decided it: `MAJORDOMUS_REASONING_MODE`, `CI`, `profile <name>`, or `default`.
164    pub source: String,
165}
166
167/// The environment variable that names the mode outright.
168pub const MODE_ENV: &str = "MAJORDOMUS_REASONING_MODE";
169/// The environment variable listing advisors to leave out (comma-separated, or `all`).
170pub const DISABLE_ENV: &str = "MAJORDOMUS_ADVISORS_DISABLE";
171/// The environment variable overriding every circuit cooldown, in seconds.
172pub const COOLDOWN_ENV: &str = "MAJORDOMUS_REASONING_COOLDOWN_SECONDS";
173
174/// Decide the mode: the variable, else a CI runner, else the active profile's declared
175/// mode, else `standard`. An unreadable word is skipped with its source, never guessed.
176///
177/// ```
178/// use majordomus_cli::reasoning::availability::{resolve_mode, ReasoningMode};
179///
180/// assert_eq!(resolve_mode(Some("offline"), true, None).mode, ReasoningMode::Offline);
181/// assert_eq!(resolve_mode(None, true, Some(("implementation", Some("strict")))).mode, ReasoningMode::Ci);
182/// let r = resolve_mode(None, false, Some(("deep-work", Some("strict"))));
183/// assert_eq!((r.mode, r.source.as_str()), (ReasoningMode::Strict, "profile deep-work"));
184/// assert_eq!(resolve_mode(Some("loud"), false, None).source, "default");
185/// ```
186pub fn resolve_mode(
187    explicit: Option<&str>,
188    ci: bool,
189    profile: Option<(&str, Option<&str>)>,
190) -> ModeResolution {
191    if let Some(mode) = explicit.and_then(ReasoningMode::parse) {
192        return ModeResolution {
193            mode,
194            source: MODE_ENV.into(),
195        };
196    }
197    if ci {
198        return ModeResolution {
199            mode: ReasoningMode::Ci,
200            source: "CI".into(),
201        };
202    }
203    if let Some((name, Some(word))) = profile {
204        if let Some(mode) = ReasoningMode::parse(word) {
205            return ModeResolution {
206                mode,
207                source: format!("profile {name}"),
208            };
209        }
210    }
211    ModeResolution {
212        mode: ReasoningMode::Standard,
213        source: "default".into(),
214    }
215}
216
217/// What the crate can observe about an advisor's installation. The one seam tests fake:
218/// [`SystemPresence`] reads the process, and an example or a test answers from a list.
219///
220/// ```
221/// use majordomus_cli::reasoning::availability::Presence;
222///
223/// struct Listed(&'static [&'static str]);
224/// impl Presence for Listed {
225///     fn executable(&self, name: &str) -> bool { self.0.contains(&name) }
226///     fn variable(&self, name: &str) -> bool { self.0.contains(&name) }
227/// }
228/// let p: &dyn Presence = &Listed(&["tool", "ACME_KEY"]);
229/// assert!(p.executable("tool") && p.variable("ACME_KEY"));
230/// assert!(!p.executable("other"));
231/// ```
232pub trait Presence {
233    /// Whether an executable of this name is on `PATH`: what says a client tool or a
234    /// local runtime is installed.
235    ///
236    /// ```
237    /// use majordomus_cli::reasoning::availability::Presence;
238    ///
239    /// struct Only(&'static str);
240    /// impl Presence for Only {
241    ///     fn executable(&self, name: &str) -> bool { name == self.0 }
242    ///     fn variable(&self, _: &str) -> bool { false }
243    /// }
244    /// assert!(Only("tool").executable("tool"));
245    /// assert!(!Only("tool").executable("other"));
246    /// ```
247    fn executable(&self, name: &str) -> bool;
248    /// Whether an environment variable of this name is set and non-empty: what says an
249    /// API advisor's credential is configured. Never its value.
250    ///
251    /// ```
252    /// use majordomus_cli::reasoning::availability::Presence;
253    ///
254    /// struct Only(&'static str);
255    /// impl Presence for Only {
256    ///     fn executable(&self, _: &str) -> bool { false }
257    ///     fn variable(&self, name: &str) -> bool { name == self.0 }
258    /// }
259    /// assert!(Only("ACME_KEY").variable("ACME_KEY"));
260    /// assert!(!Only("ACME_KEY").variable("OTHER_KEY"));
261    /// ```
262    fn variable(&self, name: &str) -> bool;
263}
264
265/// The process's own `PATH` and environment: an executable counts when a file of its
266/// name with an execute bit stands in a `PATH` directory, a variable when it is set and
267/// non-empty. A name containing `/` is never an executable on `PATH`.
268///
269/// ```
270/// use majordomus_cli::reasoning::availability::{Presence, SystemPresence};
271///
272/// std::env::set_var("MAJORDOMUS_DOCTEST_PRESENCE", "set");
273/// assert!(SystemPresence.variable("MAJORDOMUS_DOCTEST_PRESENCE"));
274/// std::env::set_var("MAJORDOMUS_DOCTEST_PRESENCE", "");
275/// assert!(!SystemPresence.variable("MAJORDOMUS_DOCTEST_PRESENCE"));
276/// assert!(!SystemPresence.executable("bin/a-path-is-not-a-name"));
277/// ```
278#[derive(Debug, Default, Clone, Copy)]
279pub struct SystemPresence;
280
281impl Presence for SystemPresence {
282    fn executable(&self, name: &str) -> bool {
283        use std::os::unix::fs::PermissionsExt;
284        if name.contains('/') {
285            return false;
286        }
287        std::env::var_os("PATH").is_some_and(|path| {
288            std::env::split_paths(&path).any(|dir| {
289                std::fs::metadata(dir.join(name))
290                    .is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
291            })
292        })
293    }
294
295    fn variable(&self, name: &str) -> bool {
296        std::env::var_os(name).is_some_and(|v| !v.is_empty())
297    }
298}
299
300/// An advisor's standing: whether it may be asked now and, when not, which kind of
301/// reason keeps it out. Only `available` advisors enter a plan.
302///
303/// ```
304/// use majordomus_cli::reasoning::availability::AdvisorStatus;
305///
306/// assert_eq!(serde_json::to_value(AdvisorStatus::NotConfigured).unwrap(), "not_configured");
307/// let s: AdvisorStatus = serde_json::from_str("\"rate_limited\"").unwrap();
308/// assert_eq!(s, AdvisorStatus::RateLimited);
309/// ```
310#[derive(
311    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
312)]
313#[serde(rename_all = "snake_case")]
314pub enum AdvisorStatus {
315    /// It may be asked. Presence was observed; whether it answers is learned by asking.
316    Available,
317    /// Not installed: its executable is not on `PATH`.
318    Unavailable,
319    /// Installed or reachable in principle, but its credential is not configured.
320    NotConfigured,
321    /// Left out by the mode or by an explicit disable.
322    Disabled,
323    /// Its recent consultations failed; its circuit is open until the cooldown passes.
324    TemporarilyFailed,
325    /// It answered with a rate limit; not asked again until the limit passes.
326    RateLimited,
327}
328
329impl AdvisorStatus {
330    /// The word that names the status, the same one its JSON form carries: what an
331    /// exclusion reason and a report print.
332    ///
333    /// ```
334    /// use majordomus_cli::reasoning::availability::AdvisorStatus;
335    ///
336    /// let s = AdvisorStatus::TemporarilyFailed;
337    /// assert_eq!(s.as_str(), "temporarily_failed");
338    /// assert_eq!(serde_json::to_value(s).unwrap(), s.as_str());
339    /// ```
340    pub fn as_str(self) -> &'static str {
341        match self {
342            Self::Available => "available",
343            Self::Unavailable => "unavailable",
344            Self::NotConfigured => "not_configured",
345            Self::Disabled => "disabled",
346            Self::TemporarilyFailed => "temporarily_failed",
347            Self::RateLimited => "rate_limited",
348        }
349    }
350}
351
352/// An open circuit: the consultation that opened it, the failures behind it, and when it
353/// closes. Derived from the records and the clock, never stored.
354///
355/// ```
356/// use majordomus_cli::reasoning::availability::*;
357/// use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, References};
358/// use majordomus_cli::reasoning::record::ConsultationStatus;
359///
360/// struct All;
361/// impl Presence for All {
362///     fn executable(&self, _: &str) -> bool { true }
363///     fn variable(&self, _: &str) -> bool { true }
364/// }
365/// let c: AdvisorCatalogue = majordomus_cli::metadata::yaml::parse_into(
366///     "version: 1\ncapabilities:\n  - id: code_review\n    description: x\nadvisors:\n  - id: a\n    title: A\n    transport: cli\n    adapter: a-cli\n    executable: a\n    capabilities: [code_review]\n",
367/// ).unwrap();
368/// let limited = [Outcome {
369///     id: "consultation-1".into(), advisor: "a".into(),
370///     status: ConsultationStatus::RateLimited, at: 0, retry_after: Some(60),
371/// }];
372/// let refs = References::default();
373/// let states = derive_availability(&c, &Inputs {
374///     refs: &refs, presence: &All, mode: ReasoningMode::Standard, disabled: &[],
375///     history: &limited, peers: &[], now: 10, cooldown: None,
376/// });
377/// let circuit: &AdvisorCircuit = states[0].circuit.as_ref().unwrap();
378/// assert_eq!(circuit.opened_by, "consultation-1");
379/// assert_eq!(circuit.until, "1970-01-01T00:01:00Z");
380/// ```
381#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
382pub struct AdvisorCircuit {
383    /// The consultation outcome that opened it.
384    pub opened_by: String,
385    /// The consecutive failures behind it.
386    pub failures: u32,
387    /// When it closes again, RFC 3339.
388    pub until: String,
389}
390
391/// One advisor as it stands now: what the catalogue declares of it, its status, the one
392/// machine word saying why, and what was probed. The row `reasoning.advisors` reports and
393/// the policy plans over.
394///
395/// ```
396/// use majordomus_cli::reasoning::availability::*;
397/// use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, References};
398///
399/// struct Nothing;
400/// impl Presence for Nothing {
401///     fn executable(&self, _: &str) -> bool { false }
402///     fn variable(&self, _: &str) -> bool { false }
403/// }
404/// let c: AdvisorCatalogue = majordomus_cli::metadata::yaml::parse_into(
405///     "version: 1\ncapabilities:\n  - id: code_review\n    description: x\nadvisors:\n  - id: a\n    title: A\n    transport: local_runtime\n    adapter: a-rt\n    executable: rt\n    capabilities: [code_review]\n",
406/// ).unwrap();
407/// let refs = References::default();
408/// let state: AdvisorState = derive_availability(&c, &Inputs {
409///     refs: &refs, presence: &Nothing, mode: ReasoningMode::Standard, disabled: &[],
410///     history: &[], peers: &[], now: 0, cooldown: None,
411/// })
412/// .remove(0);
413/// assert_eq!((state.id.as_str(), state.status), ("a", AdvisorStatus::Unavailable));
414/// assert_eq!(state.probe, "executable rt on PATH");
415/// assert!(state.circuit.is_none() && !state.recovering);
416/// ```
417#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
418pub struct AdvisorState {
419    /// The advisor id (a peer is `peer:<runtime>`).
420    pub id: String,
421    /// The human name.
422    pub title: String,
423    /// How it is reached.
424    pub transport: AdvisorTransport,
425    /// The adapter that speaks to it.
426    pub adapter: String,
427    /// The advisory capabilities it offers.
428    pub capabilities: Vec<String>,
429    /// The model it asks, when the catalogue names one: what its adapter sends.
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub model: Option<String>,
432    /// The executable its adapter runs, when it runs one.
433    #[serde(default, skip_serializing_if = "Option::is_none")]
434    pub executable: Option<String>,
435    /// The name of the credential variable its adapter reads — a name, never a value.
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub credential: Option<String>,
438    /// Its standing.
439    pub status: AdvisorStatus,
440    /// Why, as one machine word: `present`, `executable_not_found`, `credential_absent`,
441    /// `mode_ci`, `mode_offline`, `disabled_by_configuration`, `consecutive_failures`,
442    /// `rate_limited`, `authentication_failed`, `peer_linked`.
443    pub reason: String,
444    /// What was observed, for a person: which executable, which variable (never a value).
445    pub probe: String,
446    /// Available again after an open circuit's cooldown: the next consultation is its test.
447    #[serde(default)]
448    pub recovering: bool,
449    /// The open circuit, when there is one.
450    #[serde(default, skip_serializing_if = "Option::is_none")]
451    pub circuit: Option<AdvisorCircuit>,
452}
453
454/// The recorded outcome of one consultation, as availability reads it: the advisor, how
455/// the consultation ended and when. The circuit is computed from these alone.
456///
457/// ```
458/// use majordomus_cli::reasoning::availability::*;
459/// use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, References};
460/// use majordomus_cli::reasoning::record::ConsultationStatus;
461///
462/// struct All;
463/// impl Presence for All {
464///     fn executable(&self, _: &str) -> bool { true }
465///     fn variable(&self, _: &str) -> bool { true }
466/// }
467/// let c: AdvisorCatalogue = majordomus_cli::metadata::yaml::parse_into(
468///     "version: 1\ncapabilities:\n  - id: code_review\n    description: x\nadvisors:\n  - id: a\n    title: A\n    transport: cli\n    adapter: a-cli\n    executable: a\n    capabilities: [code_review]\n",
469/// ).unwrap();
470/// // a refused credential keeps the advisor out for AUTH_COOLDOWN seconds
471/// let refused = [Outcome {
472///     id: "consultation-1".into(), advisor: "a".into(),
473///     status: ConsultationStatus::AuthFailed, at: 0, retry_after: None,
474/// }];
475/// let refs = References::default();
476/// let at = |now| derive_availability(&c, &Inputs {
477///     refs: &refs, presence: &All, mode: ReasoningMode::Standard, disabled: &[],
478///     history: &refused, peers: &[], now, cooldown: None,
479/// })[0].reason.clone();
480/// assert_eq!(at(1), "authentication_failed");
481/// assert_eq!(at(AUTH_COOLDOWN), "present");
482/// ```
483#[derive(Debug, Clone, PartialEq, Eq)]
484pub struct Outcome {
485    /// The consultation record id; the tie-break of equal stamps.
486    pub id: String,
487    /// The advisor asked.
488    pub advisor: String,
489    /// How it ended.
490    pub status: ConsultationStatus,
491    /// When it was recorded, seconds since the epoch.
492    pub at: i64,
493    /// A rate limit's own retry-after, seconds.
494    pub retry_after: Option<u64>,
495}
496
497/// A linked mesh runtime, as the mesh reports it. Those carrying the feature the
498/// catalogue's `peers` block names become advisors with the id `peer:<runtime>`.
499///
500/// ```
501/// use majordomus_cli::reasoning::availability::*;
502/// use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, AdvisorTransport, References};
503///
504/// struct Nothing;
505/// impl Presence for Nothing {
506///     fn executable(&self, _: &str) -> bool { false }
507///     fn variable(&self, _: &str) -> bool { false }
508/// }
509/// let c: AdvisorCatalogue = majordomus_cli::metadata::yaml::parse_into(
510///     "version: 1\ncapabilities:\n  - id: code_review\n    description: x\npeers:\n  feature: reviews\n  adapter: mesh-review\n  capabilities: [code_review]\n",
511/// ).unwrap();
512/// let peers = [
513///     LinkedPeer { runtime: "n1-r1".into(), name: "one".into(), features: vec!["reviews".into()] },
514///     LinkedPeer { runtime: "n2-r1".into(), name: "two".into(), features: vec![] },
515/// ];
516/// let refs = References::default();
517/// let states = derive_availability(&c, &Inputs {
518///     refs: &refs, presence: &Nothing, mode: ReasoningMode::Standard, disabled: &[],
519///     history: &[], peers: &peers, now: 0, cooldown: None,
520/// });
521/// assert_eq!(states.len(), 1, "only the peer carrying the feature is an advisor");
522/// assert_eq!(states[0].id, "peer:n1-r1");
523/// assert_eq!(states[0].transport, AdvisorTransport::Peer);
524/// ```
525#[derive(Debug, Clone, PartialEq, Eq)]
526pub struct LinkedPeer {
527    /// `<node>-<runtime>`.
528    pub runtime: String,
529    /// Its display name.
530    pub name: String,
531    /// The link features it carries; only those carrying the catalogue's are advisors.
532    pub features: Vec<String>,
533}
534
535/// Consecutive transient failures that open a circuit.
536pub const FAILURES_TO_OPEN: u32 = 2;
537/// How long a circuit opened by transient failures stays open, seconds.
538pub const FAILURE_COOLDOWN: i64 = 600;
539/// How long a rate limit is honoured when it names no retry-after, seconds.
540pub const RATE_LIMIT_COOLDOWN: i64 = 900;
541/// How long an authentication failure keeps an advisor out, seconds: a credential is not
542/// fixed by asking again, so it is not asked again soon.
543pub const AUTH_COOLDOWN: i64 = 3_600;
544
545/// Everything availability reads besides the catalogue: presence, the mode, the disable
546/// list, the recorded outcomes, the linked peers and the clock. Holding them in one value
547/// is what keeps [`derive_availability`] pure.
548///
549/// ```
550/// use majordomus_cli::reasoning::availability::*;
551/// use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, References};
552///
553/// struct All;
554/// impl Presence for All {
555///     fn executable(&self, _: &str) -> bool { true }
556///     fn variable(&self, _: &str) -> bool { true }
557/// }
558/// let c: AdvisorCatalogue = majordomus_cli::metadata::yaml::parse_into(
559///     "version: 1\ncapabilities:\n  - id: code_review\n    description: x\nadvisors:\n  - id: a\n    title: A\n    transport: cli\n    adapter: a-cli\n    executable: a\n    capabilities: [code_review]\n",
560/// ).unwrap();
561/// let refs = References::default();
562/// let disabled = vec!["all".to_string()];
563/// let inputs = Inputs {
564///     refs: &refs, presence: &All, mode: ReasoningMode::Strict, disabled: &disabled,
565///     history: &[], peers: &[], now: 0, cooldown: None,
566/// };
567/// let states = derive_availability(&c, &inputs);
568/// assert_eq!(states[0].status, AdvisorStatus::Disabled);
569/// assert_eq!(states[0].reason, "disabled_by_configuration");
570/// ```
571pub struct Inputs<'a> {
572    /// The resolved references.
573    pub refs: &'a References,
574    /// Presence.
575    pub presence: &'a dyn Presence,
576    /// The mode.
577    pub mode: ReasoningMode,
578    /// Advisor ids left out explicitly; `all` leaves out every one.
579    pub disabled: &'a [String],
580    /// ReasoningRecorded consultation outcomes, any order.
581    pub history: &'a [Outcome],
582    /// Linked reviewing peers.
583    pub peers: &'a [LinkedPeer],
584    /// Now, seconds since the epoch.
585    pub now: i64,
586    /// A cooldown override for every circuit, seconds.
587    pub cooldown: Option<i64>,
588}
589
590/// Every advisor — declared ones in preference order, then linked peers by runtime — with
591/// its status. Pure over its inputs.
592///
593/// ```
594/// use majordomus_cli::reasoning::availability::*;
595/// use majordomus_cli::reasoning::catalogue::{AdvisorCatalogue, References};
596///
597/// struct Nothing;
598/// impl Presence for Nothing {
599///     fn executable(&self, _: &str) -> bool { false }
600///     fn variable(&self, _: &str) -> bool { false }
601/// }
602/// let c: AdvisorCatalogue = majordomus_cli::metadata::yaml::parse_into(
603///     "version: 1\ncapabilities:\n  - id: code_review\n    description: x\nadvisors:\n  - id: a\n    title: A\n    transport: cli\n    adapter: a-cli\n    executable: a\n    capabilities: [code_review]\n",
604/// ).unwrap();
605/// let refs = References::default();
606/// let states = derive_availability(&c, &Inputs {
607///     refs: &refs, presence: &Nothing, mode: ReasoningMode::Standard, disabled: &[],
608///     history: &[], peers: &[], now: 0, cooldown: None,
609/// });
610/// assert_eq!(states[0].status, AdvisorStatus::Unavailable);
611/// assert_eq!(states[0].reason, "executable_not_found");
612/// ```
613pub fn derive_availability(catalogue: &AdvisorCatalogue, inputs: &Inputs<'_>) -> Vec<AdvisorState> {
614    let all_disabled = inputs.disabled.iter().any(|d| d == "all");
615    let mut out = Vec::new();
616    for a in &catalogue.advisors {
617        let (probe, present, absent_status, absent_reason) = match a.transport {
618            AdvisorTransport::Api => match catalogue.credential_of(a, inputs.refs) {
619                Some(var) => (
620                    format!("credential variable {var} set"),
621                    inputs.presence.variable(var),
622                    AdvisorStatus::NotConfigured,
623                    "credential_absent",
624                ),
625                None => (
626                    "no credential variable declared".to_string(),
627                    false,
628                    AdvisorStatus::NotConfigured,
629                    "credential_undeclared",
630                ),
631            },
632            AdvisorTransport::Cli | AdvisorTransport::LocalRuntime => match &a.executable {
633                Some(exe) => (
634                    format!("executable {exe} on PATH"),
635                    inputs.presence.executable(exe),
636                    AdvisorStatus::Unavailable,
637                    "executable_not_found",
638                ),
639                None => (
640                    "no executable declared".to_string(),
641                    false,
642                    AdvisorStatus::Unavailable,
643                    "executable_undeclared",
644                ),
645            },
646            AdvisorTransport::Peer => (
647                "peers are discovered, not declared".to_string(),
648                false,
649                AdvisorStatus::Unavailable,
650                "peer_declared_statically",
651            ),
652        };
653        let mut state = AdvisorState {
654            id: a.id.clone(),
655            title: a.title.clone(),
656            transport: a.transport,
657            adapter: a.adapter.clone(),
658            capabilities: a.capabilities.clone(),
659            model: a.model.as_deref().map(|m| {
660                inputs
661                    .refs
662                    .models
663                    .resolve(m)
664                    .map_or(m, |e| e.native_id.as_str())
665                    .to_string()
666            }),
667            executable: a.executable.clone(),
668            credential: catalogue.credential_of(a, inputs.refs).map(str::to_string),
669            status: AdvisorStatus::Available,
670            reason: "present".into(),
671            probe,
672            recovering: false,
673            circuit: None,
674        };
675        if let Some((status, reason)) = gate(a.transport, &a.id, inputs, all_disabled) {
676            state.status = status;
677            state.reason = reason;
678        } else if !present {
679            state.status = absent_status;
680            state.reason = absent_reason.into();
681        } else {
682            apply_circuit(&mut state, inputs);
683        }
684        out.push(state);
685    }
686    if let Some(decl) = &catalogue.peers {
687        // by runtime, which is a peer's durable identity
688        let peers: std::collections::BTreeMap<&str, &LinkedPeer> = inputs
689            .peers
690            .iter()
691            .filter(|p| p.features.iter().any(|f| f == &decl.feature))
692            .map(|p| (p.runtime.as_str(), p))
693            .collect();
694        for p in peers.into_values() {
695            let id = format!("peer:{}", p.runtime);
696            let mut state = AdvisorState {
697                title: format!("Mesh peer {}", p.name),
698                transport: AdvisorTransport::Peer,
699                adapter: decl.adapter.clone(),
700                capabilities: decl.capabilities.clone(),
701                model: None,
702                executable: None,
703                credential: None,
704                status: AdvisorStatus::Available,
705                reason: "peer_linked".into(),
706                probe: format!("linked runtime carrying the {} feature", decl.feature),
707                recovering: false,
708                circuit: None,
709                id,
710            };
711            if let Some((status, reason)) =
712                gate(AdvisorTransport::Peer, &state.id, inputs, all_disabled)
713            {
714                state.status = status;
715                state.reason = reason;
716            } else {
717                apply_circuit(&mut state, inputs);
718            }
719            out.push(state);
720        }
721    }
722    out
723}
724
725/// The mode and the disable list, which outrank presence.
726fn gate(
727    transport: AdvisorTransport,
728    id: &str,
729    inputs: &Inputs<'_>,
730    all_disabled: bool,
731) -> Option<(AdvisorStatus, String)> {
732    match inputs.mode {
733        ReasoningMode::Ci => return Some((AdvisorStatus::Disabled, "mode_ci".into())),
734        ReasoningMode::Offline if transport.is_remote() => {
735            return Some((AdvisorStatus::Disabled, "mode_offline".into()))
736        }
737        _ => {}
738    }
739    if all_disabled || inputs.disabled.iter().any(|d| d == id) {
740        return Some((AdvisorStatus::Disabled, "disabled_by_configuration".into()));
741    }
742    None
743}
744
745/// Open or close the advisor's circuit from its recorded outcomes: newest first, a
746/// completed consultation closes it, and only failures that say something about the
747/// advisor's standing count — a malformed or empty answer, or a cancellation, is about
748/// one exchange, not about whether the advisor can be asked.
749fn apply_circuit(state: &mut AdvisorState, inputs: &Inputs<'_>) {
750    // newest first: by stamp, then id, so equal stamps still have one order
751    let by_time: std::collections::BTreeMap<(i64, &str), &Outcome> = inputs
752        .history
753        .iter()
754        .filter(|o| o.advisor == state.id)
755        .map(|o| ((o.at, o.id.as_str()), o))
756        .collect();
757    let mine: Vec<&Outcome> = by_time.into_values().rev().collect();
758    let Some(newest) = mine.first() else { return };
759    let cooldown = |default: i64| inputs.cooldown.unwrap_or(default);
760    let (status, reason, until, failures) = match newest.status {
761        ConsultationStatus::AuthFailed => (
762            AdvisorStatus::TemporarilyFailed,
763            "authentication_failed",
764            newest.at + cooldown(AUTH_COOLDOWN),
765            1,
766        ),
767        ConsultationStatus::RateLimited => {
768            let wait = newest
769                .retry_after
770                .map(|s| s as i64)
771                .unwrap_or(RATE_LIMIT_COOLDOWN);
772            (
773                AdvisorStatus::RateLimited,
774                "rate_limited",
775                newest.at + cooldown(wait),
776                1,
777            )
778        }
779        s if s.is_transient() => {
780            let run = mine.iter().take_while(|o| o.status.is_transient()).count() as u32;
781            if run < FAILURES_TO_OPEN {
782                return;
783            }
784            (
785                AdvisorStatus::TemporarilyFailed,
786                "consecutive_failures",
787                newest.at + cooldown(FAILURE_COOLDOWN),
788                run,
789            )
790        }
791        _ => return,
792    };
793    if inputs.now >= until {
794        state.recovering = true;
795        return;
796    }
797    state.status = status;
798    state.reason = reason.into();
799    state.circuit = Some(AdvisorCircuit {
800        opened_by: newest.id.clone(),
801        failures,
802        until: super::stamp_of(until),
803    });
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809
810    struct Fake {
811        executables: Vec<&'static str>,
812        variables: Vec<&'static str>,
813    }
814    impl Presence for Fake {
815        fn executable(&self, name: &str) -> bool {
816            self.executables.contains(&name)
817        }
818        fn variable(&self, name: &str) -> bool {
819            self.variables.contains(&name)
820        }
821    }
822
823    fn catalogue() -> (AdvisorCatalogue, References) {
824        let c = crate::metadata::yaml::parse_into(
825            "version: 1\ncapabilities:\n  - id: independent_reasoning\n    description: x\n  - id: code_review\n    description: y\nadvisors:\n  - id: remote\n    title: Remote\n    transport: api\n    adapter: remote-api\n    vendor: acme\n    capabilities: [independent_reasoning]\n  - id: tool\n    title: Tool\n    transport: cli\n    adapter: tool-cli\n    executable: tool\n    capabilities: [code_review]\n  - id: local\n    title: Local\n    transport: local_runtime\n    adapter: local-rt\n    executable: rt\n    capabilities: [independent_reasoning]\npeers:\n  feature: reviews\n  adapter: mesh-review\n  capabilities: [code_review]\n",
826        )
827        .unwrap();
828        let models = crate::metadata::yaml::parse_into(
829            "version: 1\nvendors:\n  - id: acme\n    title: Acme\n    credential_env: ACME_KEY\nmodels: []\n",
830        )
831        .unwrap();
832        (
833            c,
834            References {
835                providers: vec![],
836                models,
837            },
838        )
839    }
840
841    fn states(
842        presence: &Fake,
843        mode: ReasoningMode,
844        history: &[Outcome],
845        now: i64,
846    ) -> Vec<AdvisorState> {
847        let (c, refs) = catalogue();
848        derive_availability(
849            &c,
850            &Inputs {
851                refs: &refs,
852                presence,
853                mode,
854                disabled: &[],
855                history,
856                peers: &[],
857                now,
858                cooldown: None,
859            },
860        )
861    }
862
863    fn everything() -> Fake {
864        Fake {
865            executables: vec!["tool", "rt"],
866            variables: vec!["ACME_KEY"],
867        }
868    }
869
870    fn outcome(id: &str, status: ConsultationStatus, at: i64) -> Outcome {
871        Outcome {
872            id: id.into(),
873            advisor: "remote".into(),
874            status,
875            at,
876            retry_after: None,
877        }
878    }
879
880    #[test]
881    fn nothing_present_is_an_ordinary_answer() {
882        let none = Fake {
883            executables: vec![],
884            variables: vec![],
885        };
886        let s = states(&none, ReasoningMode::Standard, &[], 0);
887        let words: Vec<(&str, &str)> = s
888            .iter()
889            .map(|a| (a.status.as_str(), a.reason.as_str()))
890            .collect();
891        assert_eq!(
892            words,
893            [
894                ("not_configured", "credential_absent"),
895                ("unavailable", "executable_not_found"),
896                ("unavailable", "executable_not_found")
897            ]
898        );
899    }
900
901    #[test]
902    fn ci_admits_nothing_and_offline_admits_only_the_machine() {
903        let ci = states(&everything(), ReasoningMode::Ci, &[], 0);
904        assert!(ci
905            .iter()
906            .all(|a| a.status == AdvisorStatus::Disabled && a.reason == "mode_ci"));
907        let offline = states(&everything(), ReasoningMode::Offline, &[], 0);
908        let available: Vec<&str> = offline
909            .iter()
910            .filter(|a| a.status == AdvisorStatus::Available)
911            .map(|a| a.id.as_str())
912            .collect();
913        assert_eq!(available, ["local"]);
914    }
915
916    #[test]
917    fn two_transient_failures_open_the_circuit_and_the_cooldown_closes_it() {
918        let one = [outcome("c1", ConsultationStatus::Timeout, 100)];
919        assert_eq!(
920            states(&everything(), ReasoningMode::Standard, &one, 101)[0].status,
921            AdvisorStatus::Available
922        );
923        let two = [
924            outcome("c1", ConsultationStatus::Timeout, 100),
925            outcome("c2", ConsultationStatus::Unavailable, 110),
926        ];
927        let open = &states(&everything(), ReasoningMode::Standard, &two, 111)[0];
928        assert_eq!(open.status, AdvisorStatus::TemporarilyFailed);
929        assert_eq!(open.circuit.as_ref().unwrap().failures, 2);
930        assert_eq!(open.circuit.as_ref().unwrap().opened_by, "c2");
931        let later = &states(
932            &everything(),
933            ReasoningMode::Standard,
934            &two,
935            110 + FAILURE_COOLDOWN,
936        )[0];
937        assert_eq!(later.status, AdvisorStatus::Available);
938        assert!(later.recovering, "back after the cooldown, and marked so");
939    }
940
941    #[test]
942    fn a_success_closes_the_circuit_and_order_is_by_time_not_by_arrival() {
943        // ReasoningRecorded out of order: the success is newest, so the circuit is closed.
944        let h = [
945            outcome("c3", ConsultationStatus::Completed, 120),
946            outcome("c1", ConsultationStatus::Timeout, 100),
947            outcome("c2", ConsultationStatus::Timeout, 110),
948        ];
949        let s = &states(&everything(), ReasoningMode::Standard, &h, 121)[0];
950        assert_eq!(s.status, AdvisorStatus::Available);
951        assert!(!s.recovering);
952        // The same outcomes with the success oldest: open.
953        let h = [
954            outcome("c3", ConsultationStatus::Completed, 90),
955            outcome("c1", ConsultationStatus::Timeout, 100),
956            outcome("c2", ConsultationStatus::Timeout, 110),
957        ];
958        assert_eq!(
959            states(&everything(), ReasoningMode::Standard, &h, 111)[0].status,
960            AdvisorStatus::TemporarilyFailed
961        );
962    }
963
964    #[test]
965    fn rate_limits_and_authentication_are_their_own_states() {
966        let mut limited = outcome("c1", ConsultationStatus::RateLimited, 100);
967        limited.retry_after = Some(30);
968        let s = &states(
969            &everything(),
970            ReasoningMode::Standard,
971            &[limited.clone()],
972            120,
973        )[0];
974        assert_eq!(s.status, AdvisorStatus::RateLimited);
975        assert_eq!(
976            states(&everything(), ReasoningMode::Standard, &[limited], 131)[0].status,
977            AdvisorStatus::Available
978        );
979        let auth = [outcome("c1", ConsultationStatus::AuthFailed, 100)];
980        let s = &states(
981            &everything(),
982            ReasoningMode::Standard,
983            &auth,
984            100 + AUTH_COOLDOWN - 1,
985        )[0];
986        assert_eq!(
987            (s.status, s.reason.as_str()),
988            (AdvisorStatus::TemporarilyFailed, "authentication_failed")
989        );
990    }
991
992    #[test]
993    fn a_malformed_answer_says_nothing_about_standing() {
994        let h = [
995            outcome("c1", ConsultationStatus::Malformed, 100),
996            outcome("c2", ConsultationStatus::Malformed, 110),
997            outcome("c3", ConsultationStatus::Cancelled, 120),
998        ];
999        assert_eq!(
1000            states(&everything(), ReasoningMode::Standard, &h, 121)[0].status,
1001            AdvisorStatus::Available
1002        );
1003    }
1004
1005    #[test]
1006    fn linked_peers_become_advisors_from_the_declaration() {
1007        let (c, refs) = catalogue();
1008        let peers = [
1009            LinkedPeer {
1010                runtime: "n1-r1".into(),
1011                name: "laptop".into(),
1012                features: vec!["reviews".into()],
1013            },
1014            LinkedPeer {
1015                runtime: "n2-r1".into(),
1016                name: "ci".into(),
1017                features: vec!["sessions".into()],
1018            },
1019        ];
1020        let s = derive_availability(
1021            &c,
1022            &Inputs {
1023                refs: &refs,
1024                presence: &everything(),
1025                mode: ReasoningMode::Standard,
1026                disabled: &["tool".into()],
1027                history: &[],
1028                peers: &peers,
1029                now: 0,
1030                cooldown: None,
1031            },
1032        );
1033        let peer = s
1034            .iter()
1035            .find(|a| a.id == "peer:n1-r1")
1036            .expect("the peer is an advisor");
1037        assert_eq!(peer.capabilities, ["code_review"]);
1038        assert_eq!(peer.status, AdvisorStatus::Available);
1039        assert!(
1040            s.iter().all(|a| a.id != "peer:n2-r1"),
1041            "a peer without the feature is no advisor"
1042        );
1043        assert_eq!(
1044            s.iter().find(|a| a.id == "tool").unwrap().reason,
1045            "disabled_by_configuration"
1046        );
1047    }
1048}