dwarves-gui/src/mimir_release.rs
//! **The 🚀 Mímir release tab** — the release of the SELECTED workspace as MÍMIR sees it,
//! next to today's Release tab ([`crate::release`], untouched).
//!
//! Today's Release reads a release run's convergence file and the ramp state of the
//! checkouts on the UI's own machine. This tab reads nothing on this machine. It asks the
//! forge (`dwarf.v1.MimirRelease.Plan`, [`dwarves_embed::mimir_release`]) and draws the
//! plan the forge read from Mímir on its jera queue:
//!
//! | column | from Mímir (on the forge) |
//! |---|---|
//! | step — the release ORDER, dependencies first | `Mimir.BuildOrder` |
//! | depends on — each repo's direct dependencies, with the crates that make the edge | `Mimir.DepsOf` |
//! | convergence — `moved` · `downstream` · `converged` | `Mimir.ChangedSinceLastRelease` + `Mimir.AffectedByChange` |
//!
//! The forge answers at once: the plan it keeps, or the jera job computing it. While the
//! job is queued or running the tab shows the job (amber) and asks again — each ask one
//! short call, off the egui thread. A plan whose "what moved" could not be read shows
//! every repo `unknown`, never `converged`.
//!
//! ## This pane cannot release anything
//! There is no publish affordance here: it draws a plan.
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use dwarves_embed::mimir_release::PlanAnswer;
use facett_core::Facet;
use crate::room::Pending;
/// The feed, named the way an operator would look it up.
pub const FEED: &str = "the forge's MimirRelease.Plan (Mímir: BuildOrder · DepsOf · ChangedSinceLastRelease · AffectedByChange)";
/// Where the tab's plan comes from. Injected, so a test can stand the tab on canned
/// answers; the shipped source is [`ForgePlan`].
pub trait PlanSource: Send + Sync {
/// Ask until the plan is there (or the ask is not waiting any more), handing each
/// waiting answer to `on_wait`. Total: a failure is an answer with a state.
fn ask_until(&self, fresh: bool, on_wait: &dyn Fn(&PlanAnswer)) -> PlanAnswer;
/// Where this source asks.
fn describe(&self) -> String;
}
/// The shipped source: the forge's `MimirRelease.Plan` for one workspace.
pub struct ForgePlan {
pub endpoint: String,
pub token: String,
pub workspace: String,
}
impl PlanSource for ForgePlan {
fn ask_until(&self, fresh: bool, on_wait: &dyn Fn(&PlanAnswer)) -> PlanAnswer {
dwarves_embed::mimir_release::ask_until_planned(
&self.endpoint,
&self.token,
&self.workspace,
fresh,
on_wait,
)
}
fn describe(&self) -> String {
format!(
"MimirRelease.Plan @ {} / {}",
self.endpoint,
if self.workspace.is_empty() {
"(no workspace)"
} else {
&self.workspace
}
)
}
}
/// One repo of the plan.
#[derive(Clone, Debug, PartialEq)]
pub struct Row {
pub step: u64,
pub repo: String,
/// `moved` · `downstream` · `converged` · `unknown`.
pub status: String,
/// `(dependency, crates that make the edge)`.
pub deps: Vec<(String, Vec<String>)>,
pub deps_error: Option<String>,
}
/// The plan as the forge computed it.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct Plan {
pub workspace: String,
pub rows: Vec<Row>,
pub to_release: Vec<String>,
/// `measured` or `unknown` (what moved since the last release was not read).
pub since_last_release: String,
pub notes: Vec<String>,
pub via: String,
pub computed_at: String,
pub ms: u64,
}
fn strs(v: &serde_json::Value) -> Vec<String> {
v.as_array()
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
impl Plan {
/// The plan in a forge answer's `plan_json`, field for field. `None` when there is none.
pub fn parse(plan_json: &str) -> Option<Self> {
if plan_json.trim().is_empty() {
return None;
}
let v: serde_json::Value = serde_json::from_str(plan_json).ok()?;
let rows = v["order"]
.as_array()?
.iter()
.map(|r| Row {
step: r["step"].as_u64().unwrap_or(0),
repo: r["repo"].as_str().unwrap_or("?").to_string(),
status: r["status"].as_str().unwrap_or("unknown").to_string(),
deps: r["dependencies"]
.as_array()
.map(|a| {
a.iter()
.map(|d| {
(
d["repo"].as_str().unwrap_or("?").to_string(),
strs(&d["via"]),
)
})
.collect()
})
.unwrap_or_default(),
deps_error: r["deps_error"].as_str().map(str::to_string),
})
.collect();
Some(Self {
workspace: v["workspace"].as_str().unwrap_or("").to_string(),
rows,
to_release: strs(&v["to_release"]),
since_last_release: v["since_last_release"]
.as_str()
.unwrap_or("unknown")
.to_string(),
notes: strs(&v["notes"]),
via: v["via"].as_str().unwrap_or("").to_string(),
computed_at: v["computed_at"].as_str().unwrap_or("").to_string(),
ms: v["ms"].as_u64().unwrap_or(0),
})
}
/// How many repos are in each convergence state.
pub fn counts(&self) -> BTreeMap<String, usize> {
let mut m = BTreeMap::new();
for r in &self.rows {
*m.entry(r.status.clone()).or_default() += 1;
}
m
}
/// The repos that depend directly on `repo` (read off the rows' dependencies).
pub fn dependents_of(&self, repo: &str) -> Vec<String> {
self.rows
.iter()
.filter(|r| r.deps.iter().any(|(d, _)| d == repo))
.map(|r| r.repo.clone())
.collect()
}
}
const TEAL: egui::Color32 = egui::Color32::from_rgb(0x00, 0x69, 0x5c);
const AMBER: egui::Color32 = egui::Color32::from_rgb(0x8d, 0x6e, 0x00);
const RED: egui::Color32 = egui::Color32::from_rgb(0xc6, 0x28, 0x28);
const BLUE: egui::Color32 = egui::Color32::from_rgb(0x1e, 0x5a, 0xa8);
const GREY: egui::Color32 = egui::Color32::from_rgb(0x70, 0x70, 0x70);
fn status_color(status: &str) -> egui::Color32 {
match status {
"moved" => RED,
"downstream" => AMBER,
"converged" => TEAL,
_ => GREY,
}
}
/// The chip of a forge answer.
pub fn chip_of(a: &PlanAnswer) -> (String, egui::Color32) {
match a.state.as_str() {
"done" => ("PLANNED".into(), TEAL),
"queued" | "running" => (
format!("{} ON JERA — JOB {}", a.state.to_uppercase(), a.job_id),
AMBER,
),
"" => ("ASKING".into(), BLUE),
other => (other.to_uppercase(), RED),
}
}
/// The 🚀 Mímir release tab.
pub struct MimirReleaseTab {
source: Arc<dyn PlanSource>,
/// The forge endpoint + bearer a `{"set_workspace":…}` re-binds with (`None` for
/// an injected source).
forge: Option<(String, String)>,
workspace: String,
/// The last answer that landed (`state` empty before the first).
answer: PlanAnswer,
/// The plan drawn: the last one the forge handed out (kept while a new one is computed).
plan: Option<Plan>,
/// The ask in flight, and the waiting answer its worker saw last (the job).
pending: Option<Pending<PlanAnswer>>,
waiting: Arc<Mutex<Option<PlanAnswer>>>,
selected: Option<String>,
/// When the last answer landed (the tab asks again by itself after [`REPLAN_AFTER`]).
landed: Option<std::time::Instant>,
}
/// How old the tab lets its plan get before it asks the forge again — the forge's
/// own keep time (`dwarves::mimir_release::KEEP_FOR`), so the next ask is a new plan.
pub const REPLAN_AFTER: std::time::Duration = std::time::Duration::from_secs(5 * 60);
impl MimirReleaseTab {
/// The shipped tab: the plan of `workspace`, asked of the forge at `endpoint`.
pub fn new(
endpoint: impl Into<String>,
token: impl Into<String>,
workspace: impl Into<String>,
) -> Self {
let (endpoint, token, workspace) = (endpoint.into(), token.into(), workspace.into());
let source = Arc::new(ForgePlan {
endpoint: endpoint.clone(),
token: token.clone(),
workspace: workspace.clone(),
});
let mut me = Self::with_source(source, workspace);
me.forge = Some((endpoint, token));
me
}
/// The tab over an injected source (tests). Starts its first ask.
pub fn with_source(source: Arc<dyn PlanSource>, workspace: impl Into<String>) -> Self {
let mut me = Self {
source,
forge: None,
workspace: workspace.into(),
answer: PlanAnswer::default(),
plan: None,
pending: None,
waiting: Arc::default(),
selected: None,
landed: None,
};
me.ask(false);
me
}
/// **Ask the forge** off the egui thread (`fresh` = compute it again). An ask while
/// one is in flight is not a second one.
pub fn ask(&mut self, fresh: bool) {
if self.pending.is_some() {
return;
}
let (source, waiting) = (Arc::clone(&self.source), Arc::clone(&self.waiting));
*waiting.lock().unwrap_or_else(|e| e.into_inner()) = None;
self.pending = Some(Pending::spawn("mimir-release-ask", move || {
source.ask_until(fresh, &|w| {
*waiting.lock().unwrap_or_else(|e| e.into_inner()) = Some(w.clone())
})
}));
}
/// Non-blocking: land the ask if the worker finished. Called once per frame.
pub fn poll(&mut self) {
let Some(p) = &self.pending else { return };
if let Some(res) = p.try_take() {
self.pending = None;
self.land(res);
}
}
/// Blocking: wait for the ask in flight (a scripted caller; the window never does).
pub fn wait(&mut self) {
if let Some(p) = self.pending.take() {
let res = p.wait();
self.land(res);
}
}
pub fn is_pending(&self) -> bool {
self.pending.is_some()
}
fn land(&mut self, res: Result<PlanAnswer, ()>) {
let a = res.unwrap_or_else(|()| PlanAnswer {
state: "read_failed".into(),
note: "the ask worker died before it answered".into(),
via: self.source.describe(),
..Default::default()
});
// a plan beside any answer is drawn; an answer without one keeps the last plan
// only when it is still about it (a waiting / failed refresh of this workspace)
match Plan::parse(&a.plan_json) {
Some(p) => self.plan = Some(p),
None if a.state == "done" || a.state == "unreachable" || a.state == "refused" => {
self.plan = None
}
None => {}
}
if let Some(sel) = &self.selected {
if !self
.plan
.as_ref()
.is_some_and(|p| p.rows.iter().any(|r| &r.repo == sel))
{
self.selected = None;
}
}
self.answer = a;
self.landed = Some(std::time::Instant::now());
}
/// The answer the tab draws: the waiting one while an ask is in flight, else the
/// last that landed.
fn current(&self) -> PlanAnswer {
if self.pending.is_some() {
if let Some(w) = self
.waiting
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
{
return w;
}
}
self.answer.clone()
}
pub fn workspace(&self) -> &str {
&self.workspace
}
fn rebind(&mut self, ws: &str) {
if let Some((endpoint, token)) = &self.forge {
self.source = Arc::new(ForgePlan {
endpoint: endpoint.clone(),
token: token.clone(),
workspace: ws.to_string(),
});
// an ask of the previous workspace must not land as this one's
self.pending = None;
}
self.workspace = ws.to_string();
self.plan = None;
self.selected = None;
self.answer = PlanAnswer::default();
self.landed = None;
self.ask(false);
}
fn paint_plan(&mut self, ui: &mut egui::Ui) {
let Some(plan) = self.plan.clone() else {
return;
};
let counts = plan.counts();
let n = |k: &str| counts.get(k).copied().unwrap_or(0);
ui.horizontal_wrapped(|ui| {
ui.strong(format!("{} repos", plan.rows.len()));
ui.separator();
ui.colored_label(RED, format!("{} moved", n("moved")));
ui.colored_label(AMBER, format!("{} downstream", n("downstream")));
ui.colored_label(TEAL, format!("{} converged", n("converged")));
if n("unknown") > 0 {
ui.colored_label(GREY, format!("{} unknown", n("unknown")));
}
ui.separator();
ui.label(format!(
"to release: {}",
if plan.to_release.is_empty() {
"nothing".to_string()
} else {
plan.to_release.join(" → ")
}
));
});
if plan.since_last_release != "measured" {
ui.colored_label(AMBER, "what moved since the last release was NOT read — every repo is `unknown`, none is called converged");
}
for note in &plan.notes {
ui.label(egui::RichText::new(note).small().color(AMBER));
}
ui.label(
egui::RichText::new(format!(
"via {} · computed {} in {} ms",
plan.via, plan.computed_at, plan.ms
))
.small()
.weak(),
);
ui.separator();
let mut clicked: Option<String> = None;
egui::ScrollArea::vertical()
.id_salt("mimir-release-order")
.max_height(ui.available_height() * 0.65)
.show(ui, |ui| {
egui::Grid::new("mimir-release-grid")
.striped(true)
.num_columns(4)
.show(ui, |ui| {
ui.strong("step");
ui.strong("repo");
ui.strong("convergence");
ui.strong("depends on");
ui.end_row();
for r in &plan.rows {
ui.label(r.step.to_string());
let sel = self.selected.as_deref() == Some(r.repo.as_str());
if ui.selectable_label(sel, &r.repo).clicked() {
clicked = Some(r.repo.clone());
}
ui.colored_label(status_color(&r.status), &r.status);
match &r.deps_error {
Some(e) => ui.colored_label(RED, format!("not read: {e}")),
None => ui.label(
r.deps
.iter()
.map(|(d, _)| d.as_str())
.collect::<Vec<_>>()
.join(", "),
),
};
ui.end_row();
}
});
});
if let Some(c) = clicked {
self.selected = if self.selected.as_deref() == Some(c.as_str()) {
None
} else {
Some(c)
};
}
if let Some(sel) = self.selected.clone() {
if let Some(r) = plan.rows.iter().find(|r| r.repo == sel) {
ui.separator();
ui.strong(format!("{} — step {} · {}", r.repo, r.step, r.status));
for (d, via) in &r.deps {
ui.label(format!(
"depends on {d} via {}",
if via.is_empty() {
"—".to_string()
} else {
via.join(", ")
}
));
}
let dependents = plan.dependents_of(&r.repo);
ui.label(format!(
"depended on by: {}",
if dependents.is_empty() {
"nobody in the workspace".to_string()
} else {
dependents.join(", ")
}
));
}
}
}
}
impl Facet for MimirReleaseTab {
fn title(&self) -> &str {
"Mímir release"
}
fn ui(&mut self, ui: &mut egui::Ui) {
self.poll();
// SCAN is not a user action: no button asks again. A plan older than the
// forge keeps one is asked for again by itself (the forge then queues a new one).
if self.pending.is_none() && self.landed.is_some_and(|t| t.elapsed() > REPLAN_AFTER) {
self.ask(false);
}
if self.pending.is_some() {
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(250));
} else {
ui.ctx().request_repaint_after(REPLAN_AFTER);
}
let a = self.current();
ui.horizontal_wrapped(|ui| {
ui.heading(format!(
"🚀 Mímir release — {}",
if self.workspace.is_empty() {
"(no workspace selected)"
} else {
&self.workspace
}
));
let (label, color) = chip_of(&a);
ui.label(
egui::RichText::new(format!(" {label} "))
.color(egui::Color32::WHITE)
.background_color(color)
.strong(),
);
if self.pending.is_some() {
ui.spinner();
}
});
ui.label(
egui::RichText::new(if a.via.is_empty() {
self.source.describe()
} else {
a.via.clone()
})
.small()
.weak(),
);
if !a.note.is_empty() {
ui.label(egui::RichText::new(&a.note).small());
}
if self.plan.is_some() && a.age_ms > 0 && a.state != "done" {
ui.label(
egui::RichText::new(format!(
"the plan below is {}s old; a new one is on the queue",
a.age_ms / 1000
))
.small()
.color(AMBER),
);
}
ui.separator();
if self.plan.is_none() {
let msg = match a.state.as_str() {
"queued" | "running" => "the forge is reading the plan from Mímir on its jera queue — it is asked again until it is there",
"" => "asking the forge…",
_ => "no plan: the state above says why — nothing below is a release order",
};
ui.label(msg);
return;
}
self.paint_plan(ui);
}
fn state_json(&self) -> serde_json::Value {
let a = self.current();
let plan = self.plan.as_ref().map(|p| {
serde_json::json!({
"workspace": p.workspace,
"order": p.rows.iter().map(|r| r.repo.clone()).collect::<Vec<_>>(),
"status": p.rows.iter().map(|r| (r.repo.clone(), r.status.clone())).collect::<BTreeMap<_, _>>(),
"deps": p.rows.iter().map(|r| (r.repo.clone(), r.deps.iter().map(|(d, _)| d.clone()).collect::<Vec<_>>())).collect::<BTreeMap<_, _>>(),
"to_release": p.to_release,
"counts": p.counts(),
"since_last_release": p.since_last_release,
"notes": p.notes,
"via": p.via,
})
});
serde_json::json!({
"kind": "mimir-release",
"workspace": self.workspace,
"feed": FEED,
"via": if a.via.is_empty() { self.source.describe() } else { a.via.clone() },
"state": a.state,
"job_id": a.job_id,
"note": a.note,
"pending": self.pending.is_some(),
"plan": plan,
"selected": self.selected,
// the two promises of this pane: it reads no checkout here, it publishes nothing
"reads_local_checkouts": false,
"can_publish": false,
})
}
fn update_json(&mut self, msg_json: &str) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else {
return;
};
if let Some(ws) = v.get("set_workspace").and_then(|t| t.as_str()) {
self.rebind(ws);
return;
}
if v.get("plan_again").and_then(|t| t.as_bool()) == Some(true) {
self.ask(true);
return;
}
if v.get("wait").and_then(|t| t.as_bool()) == Some(true) {
self.wait();
return;
}
if let Some(r) = v.get("select").and_then(|t| t.as_str()) {
self.selected = self
.plan
.as_ref()
.filter(|p| p.rows.iter().any(|x| x.repo == r))
.map(|_| r.to_string());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
/// A plan as the forge hands it out: `base ← mid ← top`, `side` alone, `mid` moved.
pub(crate) fn plan_json() -> String {
serde_json::json!({
"workspace": "nordisk",
"computed_at": "2026-10-15T20:00:00Z",
"order": [
{ "step": 1, "repo": "base", "status": "converged", "dependencies": [], "deps_error": null },
{ "step": 2, "repo": "side", "status": "converged", "dependencies": [], "deps_error": null },
{ "step": 3, "repo": "mid", "status": "moved", "dependencies": [{ "repo": "base", "via": ["base-core"] }], "deps_error": null },
{ "step": 4, "repo": "top", "status": "downstream", "dependencies": [{ "repo": "mid", "via": ["mid-api"] }], "deps_error": null },
],
"changed": ["mid"], "affected": ["mid", "top"], "to_release": ["mid", "top"],
"counts": { "converged": 2, "moved": 1, "downstream": 1 },
"since_last_release": "measured", "notes": [], "via": "canned", "ms": 7,
})
.to_string()
}
/// A source that walks a script of forge answers, the way `ask_until` does.
struct Scripted {
script: Vec<PlanAnswer>,
asked: Mutex<Vec<bool>>,
}
impl PlanSource for Scripted {
fn ask_until(&self, fresh: bool, on_wait: &dyn Fn(&PlanAnswer)) -> PlanAnswer {
self.asked.lock().unwrap().push(fresh);
let mut it = self.script.iter();
let mut a = it.next().cloned().unwrap_or_default();
while a.is_waiting() {
on_wait(&a);
a = it.next().cloned().unwrap_or_default();
}
a
}
fn describe(&self) -> String {
"scripted".into()
}
}
fn ans(state: &str, plan: &str) -> PlanAnswer {
PlanAnswer {
state: state.into(),
job_id: "j-9".into(),
plan_json: plan.into(),
via: "scripted".into(),
..Default::default()
}
}
/// The tab draws the forge's plan: Mímir's order, each repo's convergence and its
/// dependencies — after the jera job it waited for — and offers nothing to publish.
#[test]
fn the_tab_draws_the_plan_the_forge_read_from_mimir() {
let src = Arc::new(Scripted {
script: vec![
ans("queued", ""),
ans("running", ""),
ans("done", &plan_json()),
],
asked: Mutex::default(),
});
let mut tab = MimirReleaseTab::with_source(src.clone(), "nordisk");
tab.wait();
let st = Facet::state_json(&tab);
assert_eq!(st["state"], "done", "{st}");
assert_eq!(
st["plan"]["order"],
serde_json::json!(["base", "side", "mid", "top"])
);
assert_eq!(st["plan"]["status"]["mid"], "moved");
assert_eq!(st["plan"]["status"]["top"], "downstream");
assert_eq!(st["plan"]["deps"]["top"], serde_json::json!(["mid"]));
assert_eq!(st["plan"]["to_release"], serde_json::json!(["mid", "top"]));
assert_eq!(
(
st["can_publish"].as_bool(),
st["reads_local_checkouts"].as_bool()
),
(Some(false), Some(false))
);
// a repo selected shows who depends on it
Facet::update_json(&mut tab, r#"{"select":"mid"}"#);
assert_eq!(Facet::state_json(&tab)["selected"], "mid");
assert_eq!(tab.plan.as_ref().unwrap().dependents_of("mid"), ["top"]);
// "plan again" asks fresh
Facet::update_json(&mut tab, r#"{"plan_again":true}"#);
tab.wait();
assert_eq!(*src.asked.lock().unwrap(), [false, true]);
}
/// No plan is drawn from a forge that did not plan: an unreachable forge is its
/// state, with no order and no number.
#[test]
fn an_unplanned_answer_draws_no_order() {
for state in ["unreachable", "failed", "refused"] {
let src = Arc::new(Scripted {
script: vec![PlanAnswer {
state: state.into(),
note: "why".into(),
..Default::default()
}],
asked: Mutex::default(),
});
let mut tab = MimirReleaseTab::with_source(src, "nordisk");
tab.wait();
let st = Facet::state_json(&tab);
assert_eq!(
(st["state"].as_str(), st["plan"].is_null()),
(Some(state), true),
"{st}"
);
let (label, color) = chip_of(&tab.answer);
assert_eq!((label, color), (state.to_uppercase(), RED));
}
}
/// A waiting answer is drawn as the jera job — amber, never red.
#[test]
fn a_waiting_answer_is_its_jera_job() {
let (label, color) = chip_of(&ans("running", ""));
assert_eq!(
(label.as_str(), color),
("RUNNING ON JERA — JOB j-9", AMBER)
);
}
}