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}