Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/dwarves

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)
        );
    }
}