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/actions.rs

//! **The verbs a typ bears** — gap #7 of `.nornir/build-thing-model-design.md`, as
//! DATA (the owner's ruling, 2026-08-26): after BYGG the selected node's menu shows, by
//! typ only, (a) always a BUILDINFO (the receipt, readable), (b) the verbs, (c) the run
//! SURFACES the console leaf draws.
//!
//! | typ | verbs | where (a `val`) | surfaces |
//! |---|---|---|---|
//! | lib · wasm | none — RUN must not appear | — | — |
//! | exe | RUN | `run.where` = container \| vm (a host process is not an option — the containers rule) | log + exit code |
//! | service | RUN | container \| vm | log + exit code |
//! | container | RUN (draupnir) | — | log + exit code |
//! | kernel | BOOT (vm) | — | serial console + framebuffer |
//! | vm · iso · installer | BOOT (vm) | — | serial console + framebuffer |
//! | znippy | UNPACK | — | listing — *not an `ArtifactKind` today; the row waits for the typ* |
//!
//! This table is the LOCAL twin of edda's `actions(kind)` (stage 2 of the model, not
//! yet on the wasm ABI): the typ is read off the BUILD receipt, the answer is looked
//! up here, and the pane renders the answer — no if-chains on a kind in the pane.
//! When edda's `actions(kind)` lands over wasm, [`actions`] is what it replaces, and
//! nothing that reads an [`Actions`] changes.
//!
//! **Two receipts, never one** (law E): the BUILD receipt carries the typ
//! (`nornir_build_thing::contract::Receipt`, in the artifact's section); the RUN
//! receipt ([`RunReceipt`]) carries the outcome — exit code, booted-to-login, service
//! up — and is what a re-reader of the ledger gets for "what happened when it ran".

use nornir_build_thing::contract::PaneKind;
use nornir_build_thing::variant::ArtifactKind;
use serde_json::{json, Value};

/// A verb the menu offers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verb {
    /// Run the artifact (an exe in a container or a vm; a container through draupnir).
    Run,
    /// Boot the medium (a vm, an iso, an installer, a kernel) — serial + framebuffer.
    Boot,
    /// Unpack an archive and list it.
    Unpack,
}

impl Verb {
    /// The word on the button — and the word a robot addresses.
    #[must_use]
    pub fn label(self) -> &'static str {
        match self {
            Verb::Run => "RUN",
            Verb::Boot => "BOOT",
            Verb::Unpack => "UNPACK",
        }
    }
}

/// **The `where` val an exe's RUN offers** — the only two places a built program may
/// run from ⚒. A host process is deliberately not in the list.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Where {
    /// The val's path, in the settings vocabulary: `run.where`.
    pub path: &'static str,
    /// The vocabulary.
    pub choices: Vec<&'static str>,
    /// The default.
    pub default: &'static str,
}

/// A run surface the console leaf draws.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Surface {
    /// stdout / the container log.
    Log,
    /// The process's or container's exit code.
    ExitCode,
    /// The guest's serial console.
    Serial,
    /// The guest's framebuffer.
    Framebuffer,
    /// An archive listing.
    Listing,
}

impl Surface {
    /// The word a robot reads.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Surface::Log => "log",
            Surface::ExitCode => "exit_code",
            Surface::Serial => "serial",
            Surface::Framebuffer => "framebuffer",
            Surface::Listing => "listing",
        }
    }
}

/// **What a typ bears**: its verbs, the `where` val when a verb has one, and the
/// surfaces the console draws while the verb runs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Actions {
    /// The typ the answer is about.
    pub typ: ArtifactKind,
    /// The verbs, in menu order. Empty for a lib or a wasm.
    pub verbs: Vec<Verb>,
    /// The `where` val, when RUN offers one.
    pub where_: Option<Where>,
    /// The surfaces the console draws.
    pub surfaces: Vec<Surface>,
    /// The console pane the surfaces live in — the SAME `PaneKind` the console leaf
    /// already dispatches on, so the surfaces and the pane cannot disagree.
    pub pane: PaneKind,
}

impl Actions {
    /// Whether RUN is among the verbs.
    #[must_use]
    pub fn offers(&self, v: Verb) -> bool {
        self.verbs.contains(&v)
    }

    /// The answer, as data.
    #[must_use]
    pub fn state_json(&self) -> Value {
        json!({
            "typ": self.typ.as_str(),
            "verbs": self.verbs.iter().map(|v| v.label()).collect::<Vec<_>>(),
            "where": self.where_.as_ref().map(|w| json!({
                "path": w.path,
                "choices": w.choices,
                "default": w.default,
            })),
            "surfaces": self.surfaces.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
        })
    }
}

impl From<nornir_build_thing::verbs::Action> for Verb {
    fn from(a: nornir_build_thing::verbs::Action) -> Self {
        match a {
            nornir_build_thing::verbs::Action::Run => Verb::Run,
            nornir_build_thing::verbs::Action::Boot => Verb::Boot,
            nornir_build_thing::verbs::Action::Unpack => Verb::Unpack,
        }
    }
}

/// **The table.** One answer per typ; nothing in the pane branches on a kind. The
/// verbs are edda's (`nornir_build_thing::verbs::actions_for` — the same table the
/// build thing answers over wasm); the `where` val, the surfaces and the pane are the
/// local half edda's `Action` does not carry yet.
#[must_use]
pub fn actions(typ: ArtifactKind) -> Actions {
    with_verbs(
        typ,
        nornir_build_thing::verbs::actions_for(typ)
            .into_iter()
            .map(Verb::from)
            .collect(),
    )
}

/// [`actions`] with the verbs ANSWERED — by the loaded build thing over wasm, or by
/// edda's table — and the where / surfaces / pane from the local half.
#[must_use]
pub fn with_verbs(typ: ArtifactKind, verbs: Vec<Verb>) -> Actions {
    let mut a = local(typ);
    a.verbs = verbs;
    a
}

fn local(typ: ArtifactKind) -> Actions {
    let where_exe = Some(Where {
        path: "run.where",
        choices: vec!["container", "vm"],
        default: "container",
    });
    match typ {
        // A produced FILE SET has no verb and no surface of its own: `pip install`
        // is what consumes it, on a machine this forge does not own. Same row as
        // lib/wasm, and edda's `verbs::VERBS` says the same thing — this half is
        // the pane's, and it must not disagree with it.
        ArtifactKind::Lib | ArtifactKind::Wasm | ArtifactKind::Package => Actions {
            typ,
            verbs: Vec::new(),
            where_: None,
            surfaces: Vec::new(),
            pane: PaneKind::None,
        },
        ArtifactKind::Exe | ArtifactKind::Service => Actions {
            typ,
            verbs: vec![Verb::Run],
            where_: where_exe,
            surfaces: vec![Surface::Log, Surface::ExitCode],
            pane: PaneKind::Exec,
        },
        ArtifactKind::Container => Actions {
            typ,
            verbs: vec![Verb::Run],
            where_: None,
            surfaces: vec![Surface::Log, Surface::ExitCode],
            pane: PaneKind::Log,
        },
        ArtifactKind::Kernel | ArtifactKind::Vm | ArtifactKind::Iso | ArtifactKind::Installer => {
            Actions {
                typ,
                verbs: vec![Verb::Boot],
                where_: None,
                surfaces: vec![Surface::Serial, Surface::Framebuffer],
                pane: PaneKind::Console,
            }
        }
    }
}

/// The typ a build receipt names, parsed by the model crate's own reader — kept out of the
/// scene so the scene dispatches on no kind.
#[must_use]
pub fn typ_of(target: &str) -> ArtifactKind {
    ArtifactKind::from(target.to_string())
}

/// The verbs the model crate's table bears for a typ.
#[must_use]
pub fn verbs_for(kind: ArtifactKind) -> Vec<nornir_build_thing::verbs::Action> {
    nornir_build_thing::verbs::actions_for(kind)
}

/// A refusing verdict, in the model crate's type.
#[must_use]
pub fn refuse(why: impl Into<String>) -> nornir_build_thing::plugin::Verdict {
    nornir_build_thing::plugin::Verdict::refuse(why)
}

/// **The RUN receipt** — the second receipt (law E): what happened when the built
/// artifact ran. Lives beside the build receipt, never inside it.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RunReceipt {
    /// The typ, off the build receipt.
    pub typ: String,
    /// The verb that ran.
    pub verb: String,
    /// Where it ran (`container` / `vm`), when the verb offered a choice.
    pub where_: Option<String>,
    /// The artifact's sha, off the build outcome, so the two receipts pair.
    pub artifact_sha: Option<String>,
    /// The outcome, in words the console measured: `exit 0`, `booted-to-login`,
    /// `service up`, or `running` while it is.
    pub outcome: String,
}

impl RunReceipt {
    /// As data.
    #[must_use]
    pub fn state_json(&self) -> Value {
        json!({
            "typ": self.typ,
            "verb": self.verb,
            "where": self.where_,
            "artifact_sha": self.artifact_sha,
            "outcome": self.outcome,
        })
    }
}

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

    /// **By typ only.** A lib bears no verb; an exe bears RUN with container|vm and
    /// nothing else; a container bears RUN; media boot; and every answer names the
    /// console pane the surfaces live in.
    #[test]
    fn the_table_answers_by_typ_only() {
        let lib = actions(ArtifactKind::Lib);
        assert!(lib.verbs.is_empty() && lib.where_.is_none() && lib.surfaces.is_empty());
        assert_eq!(lib.pane, PaneKind::None);
        let wasm = actions(ArtifactKind::Wasm);
        assert!(!wasm.offers(Verb::Run));
        let exe = actions(ArtifactKind::Exe);
        assert_eq!(exe.verbs, vec![Verb::Run]);
        assert_eq!(
            exe.where_.as_ref().map(|w| w.choices.clone()),
            Some(vec!["container", "vm"])
        );
        assert_eq!(exe.pane, PaneKind::Exec);
        assert_eq!(exe.surfaces, vec![Surface::Log, Surface::ExitCode]);
        let c = actions(ArtifactKind::Container);
        assert_eq!(c.verbs, vec![Verb::Run]);
        assert!(c.where_.is_none());
        for medium in [
            ArtifactKind::Kernel,
            ArtifactKind::Iso,
            ArtifactKind::Installer,
            ArtifactKind::Vm,
        ] {
            let a = actions(medium);
            assert_eq!(a.verbs, vec![Verb::Boot], "{medium:?}");
            assert_eq!(a.surfaces, vec![Surface::Serial, Surface::Framebuffer]);
            assert_eq!(a.pane, PaneKind::Console);
        }
        let j = exe.state_json();
        assert_eq!(j["verbs"], json!(["RUN"]));
        assert_eq!(j["where"]["choices"], json!(["container", "vm"]));
    }
}