From 3540e0a0d0b6c7980874f4c30d472272c4f59d3f Mon Sep 17 00:00:00 2001 From: Roman Inflianskas Date: Tue, 4 Aug 2026 09:56:47 +0000 Subject: [PATCH 1/4] build(deps): bump lemonade embeddable to 11.5.1 Move the pinned Lemonade embeddable runtime from 10.10.0 to 11.5.1. Asset naming is unchanged between the two releases, so the download URLs only need the version substituted. Also refresh a stale lemonade version in an e2e-report test fixture so it stops drifting from the pinned version. Signed-off-by: Roman Inflianskas --- crates/e2e-report/src/lib.rs | 4 ++-- engines/lemonade/src/lib.rs | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/e2e-report/src/lib.rs b/crates/e2e-report/src/lib.rs index f0ea1281..6116b06c 100644 --- a/crates/e2e-report/src/lib.rs +++ b/crates/e2e-report/src/lib.rs @@ -2443,7 +2443,7 @@ mod tests { let platform = r#"{ "platform_slug": "mi300x", "capability": {"effective_serve_engine": "vllm"}, - "versions": {"os":"Ubuntu 24.04.3 LTS","rocm":"7.13.0","vllm":"0.23.0+rocm723","lemonade":"10.6.0"}, + "versions": {"os":"Ubuntu 24.04.3 LTS","rocm":"7.13.0","vllm":"0.23.0+rocm723","lemonade":"11.5.1"}, "expectations": [ {"id":"serve-x","effective_engine":"vllm","expected":"pass"} ] @@ -2455,7 +2455,7 @@ mod tests { "Ubuntu 24.04.3 LTS", "ROCm 7.13.0", "vLLM 0.23.0+rocm723", - "lemonade 10.6.0", + "lemonade 11.5.1", ] { assert!(md.contains(token), "matrix cell missing {token:?}:\n{md}"); } diff --git a/engines/lemonade/src/lib.rs b/engines/lemonade/src/lib.rs index a512156e..06aa8442 100644 --- a/engines/lemonade/src/lib.rs +++ b/engines/lemonade/src/lib.rs @@ -32,7 +32,7 @@ use std::process::{Command as ProcessCommand, Stdio}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; const ENGINE_NAME: &str = "lemonade"; -const LEMONADE_VERSION: &str = "10.10.0"; +const LEMONADE_VERSION: &str = "11.5.1"; const DEFAULT_HOST: &str = "127.0.0.1"; const DEFAULT_MODEL: &str = "Qwen3-4B-Instruct-2507-GGUF"; const DEFAULT_MODEL_REPO_DIR: &str = "models--unsloth--Qwen3-4B-Instruct-2507-GGUF"; @@ -75,14 +75,14 @@ const MAX_EMBEDDABLE_SEARCH_DEPTH: usize = 4; /// stack on a pathological tree; it is far above any plausible archive layout. const MAX_COPY_RECURSION_DEPTH: usize = 64; -const EMBEDDABLE_WINDOWS_ARCHIVE_NAME: &str = "lemonade-embeddable-10.10.0-windows-x64.zip"; -const EMBEDDABLE_LINUX_ARCHIVE_NAME: &str = "lemonade-embeddable-10.10.0-ubuntu-x64.tar.gz"; -const EMBEDDABLE_WINDOWS_URL: &str = "https://github.com/lemonade-sdk/lemonade/releases/download/v10.10.0/lemonade-embeddable-10.10.0-windows-x64.zip"; -const EMBEDDABLE_LINUX_URL: &str = "https://github.com/lemonade-sdk/lemonade/releases/download/v10.10.0/lemonade-embeddable-10.10.0-ubuntu-x64.tar.gz"; +const EMBEDDABLE_WINDOWS_ARCHIVE_NAME: &str = "lemonade-embeddable-11.5.1-windows-x64.zip"; +const EMBEDDABLE_LINUX_ARCHIVE_NAME: &str = "lemonade-embeddable-11.5.1-ubuntu-x64.tar.gz"; +const EMBEDDABLE_WINDOWS_URL: &str = "https://github.com/lemonade-sdk/lemonade/releases/download/v11.5.1/lemonade-embeddable-11.5.1-windows-x64.zip"; +const EMBEDDABLE_LINUX_URL: &str = "https://github.com/lemonade-sdk/lemonade/releases/download/v11.5.1/lemonade-embeddable-11.5.1-ubuntu-x64.tar.gz"; const EMBEDDABLE_WINDOWS_SHA256: &str = - "3bbbf755b438ba016a9cb828c71833156b3f64feba9e65b3514857b4183c1026"; + "50a133bbc35c4f3f8971eafef2c9fe56c4bbbfb0f1032bf8728324d8d8c8a0e1"; const EMBEDDABLE_LINUX_SHA256: &str = - "22962975980b6f1aa90201de93859d21214c84787ad4476bdc843dcdd19ac160"; + "bdfd3c3e5d6eda5101c8a32f36e6dd9236ceb9ab2eb66734c32628e6e86e18ac"; #[derive(Parser)] #[command(name = "rocm-engine-lemonade")] From 5315d9f38f26df6db1befb7b393673497d2ec839 Mon Sep 17 00:00:00 2001 From: Roman Inflianskas Date: Tue, 4 Aug 2026 11:16:53 +0000 Subject: [PATCH 2/4] build(deps): bump lemonade offline fallback to 11.5.1 The dash offline fallback version is documented to track the latest release ("Bump deliberately"), but it still named 10.6.0 while the engine pin moved to 11.5.1. A host whose GitHub releases API call fails would silently install a runtime several minor versions behind the pin. Also correct the host-triple mapping doc, which claimed the release ships only the three archives we select; it also publishes ubuntu-arm64. Signed-off-by: Roman Inflianskas --- crates/rocm-dash-tui/src/skills.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/rocm-dash-tui/src/skills.rs b/crates/rocm-dash-tui/src/skills.rs index 55968c7d..2d94a120 100644 --- a/crates/rocm-dash-tui/src/skills.rs +++ b/crates/rocm-dash-tui/src/skills.rs @@ -257,17 +257,17 @@ pub fn auto_config_change(detected_endpoint: Option<&str>) -> Option` token + archive extension. `None` for an -/// unsupported triple (the release ships only these three). +/// unsupported triple (only these three are supported here; the release also +/// ships `ubuntu-arm64`, which we do not select). fn embeddable_os_arch(os: &str, arch: &str) -> Option<(&'static str, &'static str)> { match (os, arch) { ("linux", "x86_64") => Some(("ubuntu-x64", "tar.gz")), @@ -294,7 +295,7 @@ fn server_bin_for(os: &str) -> &'static str { } } -/// Strip a leading `v` from a release tag (`v10.6.0` → `10.6.0`). +/// Strip a leading `v` from a release tag (`v11.5.1` → `11.5.1`). fn strip_v(tag: &str) -> &str { tag.strip_prefix('v').unwrap_or(tag) } From dc5b213a9d590bd2202d03b99f9bd4d76461605c Mon Sep 17 00:00:00 2001 From: Roman Inflianskas Date: Tue, 4 Aug 2026 12:13:53 +0000 Subject: [PATCH 3/4] refactor: derive lemonade artifact names from a single pinned version The Lemonade embeddable version was written out seven times across five constants in the engine adapter, and again as an independent constant in the dashboard TUI. Nothing tied those together, so they drifted: the two constants sat five minor versions apart, the runtime-id label in the CLI was four behind both, and a doc comment had been stale since 10.10.0. Move the pin into `runtime-deps.toml` at the repository root and add a small `rocm-deps` crate whose build script turns each `[runtime.]` field into a constant. Archive names, download URLs, the dashboard's offline fallback, and the managed runtime-id label are all derived from that one value, so a bump is a one-line edit and the tree can no longer hold two different Lemonade versions. Generating at build time rather than committing generated source means there is nothing to drift and no `--check` gate to keep honest. The schema is a table per runtime with one or more string fields, so a runtime needing more than a version (a version plus an ABI tag, say) is additive rather than a reshape. No behaviour change beyond the runtime-id label, which now matches the `env_id` the adapter actually reports instead of naming an older release. Signed-off-by: Roman Inflianskas Signed-off-by: Roman Inflianskas --- .github/workflows/ci.yml | 4 ++ Cargo.lock | 10 ++++ Cargo.toml | 2 + apps/rocm/Cargo.toml | 1 + apps/rocm/src/main.rs | 17 ++++-- crates/rocm-dash-tui/Cargo.toml | 1 + crates/rocm-dash-tui/src/skills.rs | 45 +++++++++++---- crates/rocm-deps/Cargo.toml | 17 ++++++ crates/rocm-deps/build.rs | 77 ++++++++++++++++++++++++++ crates/rocm-deps/src/lib.rs | 89 ++++++++++++++++++++++++++++++ docs/engine-plugins.md | 9 +++ engines/lemonade/Cargo.toml | 1 + engines/lemonade/src/lib.rs | 68 +++++++++++++---------- runtime-deps.toml | 18 ++++++ 14 files changed, 313 insertions(+), 46 deletions(-) create mode 100644 crates/rocm-deps/Cargo.toml create mode 100644 crates/rocm-deps/build.rs create mode 100644 crates/rocm-deps/src/lib.rs create mode 100644 runtime-deps.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 643ff719..609be8f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,9 @@ jobs: - 'rust-toolchain*' # clippy runs `cargo xtask manifest --check` against this file. - 'MANIFEST.md' + # rocm-deps compiles the pins into constants, so a pin change is a + # source change even though no *.rs file moved. + - 'runtime-deps.toml' - '.github/workflows/**' # build-and-test runs cargo AND the python/shell smoke steps plus the # install-lifecycle E2E (`cargo xtask package` + the real installer), @@ -100,6 +103,7 @@ jobs: - '**/Cargo.toml' - 'Cargo.lock' - 'rust-toolchain*' + - 'runtime-deps.toml' - 'scripts/**' - 'xtask/**' - 'engines/**' diff --git a/Cargo.lock b/Cargo.lock index d88c4b3f..302087dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3603,6 +3603,7 @@ dependencies = [ "rocm-core", "rocm-dash-daemon", "rocm-dash-tui", + "rocm-deps", "rocm-engine-lemonade", "rocm-engine-protocol", "rocm-engine-vllm", @@ -3709,6 +3710,7 @@ dependencies = [ "reqwest 0.13.4", "rig-core", "rocm-dash-core", + "rocm-deps", "serde", "serde_json", "thiserror 2.0.18", @@ -3719,6 +3721,13 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "rocm-deps" +version = "0.1.0" +dependencies = [ + "toml", +] + [[package]] name = "rocm-engine-lemonade" version = "0.1.0" @@ -3727,6 +3736,7 @@ dependencies = [ "clap", "flate2", "rocm-core", + "rocm-deps", "rocm-engine-protocol", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 9e216909..e95a3da6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,8 @@ members = [ "apps/rocm", "apps/rocmd", "crates/rocm-core", + # Pinned third-party runtime versions, generated from `runtime-deps.toml`. + "crates/rocm-deps", "crates/rocm-engine-protocol", # rocm-dash telemetry/dashboard libraries (rocm-dash merge). "crates/rocm-dash-core", diff --git a/apps/rocm/Cargo.toml b/apps/rocm/Cargo.toml index fc33aefb..bba82279 100644 --- a/apps/rocm/Cargo.toml +++ b/apps/rocm/Cargo.toml @@ -18,6 +18,7 @@ crossterm.workspace = true flate2 = "1.1" keyring-core.workspace = true rocm-core = { path = "../../crates/rocm-core" } +rocm-deps = { path = "../../crates/rocm-deps" } # rocm-dash unified dashboard launch. The # telemetry daemon + ratatui-0.30 TUI are launched from the `dash` verb; tokio # drives the async daemon/TUI from the otherwise-sync `rocm` binary. diff --git a/apps/rocm/src/main.rs b/apps/rocm/src/main.rs index 248e331d..32070d5f 100644 --- a/apps/rocm/src/main.rs +++ b/apps/rocm/src/main.rs @@ -3483,7 +3483,7 @@ fn resolve_engine_install_runtime_id( runtime_id: Option, ) -> Result { if engine_manages_own_runtime(engine) { - return Ok(runtime_id.unwrap_or_else(|| managed_engine_runtime_id(engine).to_owned())); + return Ok(runtime_id.unwrap_or_else(|| managed_engine_runtime_id(engine))); } let Some(selector) = runtime_id .or_else(|| config.active_runtime_key.clone()) @@ -3587,10 +3587,15 @@ fn env_root_for_service( } } -fn managed_engine_runtime_id(engine: &str) -> &'static str { +/// Label recorded for the runtime a self-managing engine installs for itself. +/// +/// For `lemonade` this must be the `env_id` its adapter reports, which is +/// derived from the single Lemonade pin — it was previously a hand-written +/// literal and had drifted several minor versions behind what is installed. +fn managed_engine_runtime_id(engine: &str) -> String { match engine { - "lemonade" => "lemonade-embeddable-10.6.0", - _ => "managed-engine-runtime", + "lemonade" => format!("lemonade-embeddable-{}", rocm_deps::LEMONADE_VERSION), + _ => "managed-engine-runtime".to_owned(), } } @@ -3602,7 +3607,7 @@ fn ensure_self_managed_engine_ready( if !engine_manages_own_runtime(engine) { return Ok(()); } - let runtime_id = managed_engine_runtime_id(engine).to_owned(); + let runtime_id = managed_engine_runtime_id(engine); let env_root = env_root_for_self_managed_engine(paths, config)?; let detect = engine_request::<_, DetectResponse>( Some(paths), @@ -22973,7 +22978,7 @@ ID_LIKE="suse opensuse" assert!(error.contains("no active ROCm runtime is configured")); assert_eq!( resolve_engine_install_runtime_id(&paths, &RocmCliConfig::default(), "lemonade", None)?, - "lemonade-embeddable-10.6.0" + format!("lemonade-embeddable-{}", rocm_deps::LEMONADE_VERSION), ); write_test_pip_runtime( &paths, diff --git a/crates/rocm-dash-tui/Cargo.toml b/crates/rocm-dash-tui/Cargo.toml index d180bc4a..772e8dbb 100644 --- a/crates/rocm-dash-tui/Cargo.toml +++ b/crates/rocm-dash-tui/Cargo.toml @@ -16,6 +16,7 @@ path = "src/lib.rs" [dependencies] rocm-dash-core = { path = "../rocm-dash-core" } +rocm-deps = { path = "../rocm-deps" } # Unified dashboard TUI base: ratatui 0.30 + crossterm 0.28. This is the only # ratatui in the workspace — the `rocm` binary no longer carries a (dead) 0.29 # dependency, so no second major is pulled in. Exact wrapped-row counts must diff --git a/crates/rocm-dash-tui/src/skills.rs b/crates/rocm-dash-tui/src/skills.rs index 2d94a120..6b3b657b 100644 --- a/crates/rocm-dash-tui/src/skills.rs +++ b/crates/rocm-dash-tui/src/skills.rs @@ -254,10 +254,18 @@ pub fn auto_config_change(detected_endpoint: Option<&str>) -> Option String { + rocm_deps::LEMONADE_VERSION.to_owned() +} /// A selected embeddable archive for a host triple — enough to download, extract, /// and locate the server binary. Pure data; no I/O. @@ -297,7 +305,7 @@ fn server_bin_for(os: &str) -> &'static str { /// Strip a leading `v` from a release tag (`v11.5.1` → `11.5.1`). fn strip_v(tag: &str) -> &str { - tag.strip_prefix('v').unwrap_or(tag) + rocm_deps::strip_v(tag) } /// PURE: build the canonical embeddable artifact for `(os, arch, version)` with no @@ -305,10 +313,8 @@ fn strip_v(tag: &str) -> &str { pub fn embeddable_artifact(os: &str, arch: &str, version: &str) -> Option { let (os_arch, ext) = embeddable_os_arch(os, arch)?; let ver = strip_v(version); - let archive_name = format!("lemonade-embeddable-{ver}-{os_arch}.{ext}"); - let url = format!( - "https://github.com/{LEMONADE_GITHUB_REPO}/releases/download/v{ver}/{archive_name}" - ); + let archive_name = rocm_deps::lemonade_archive_name(ver, os_arch, ext); + let url = rocm_deps::lemonade_download_url(ver, &archive_name); Some(EmbeddableArtifact { version: ver.to_string(), url, @@ -507,9 +513,26 @@ mod tests { assert_eq!(b.server_bin, "lemond.exe"); // Unsupported triple → None. assert!(embeddable_artifact("linux", "aarch64", "10.6.0").is_none()); - // The fallback const resolves for the common host. + } + + /// The offline fallback and the version the `lemonade` engine adapter + /// installs must never disagree. They cannot: both come from the single + /// `runtime-deps.toml` pin, and this pins that down against a regression + /// that reintroduces a second constant. + #[test] + fn fallback_version_is_the_single_pinned_version() { + let fallback = lemonade_embeddable_fallback_version(); + assert_eq!(fallback, rocm_deps::LEMONADE_VERSION); + let artifact = embeddable_artifact("linux", "x86_64", &fallback).expect("linux"); + assert_eq!(artifact.version, fallback); + assert_eq!( + artifact.archive_name, + rocm_deps::lemonade_archive_name(&fallback, "ubuntu-x64", "tar.gz") + ); assert!( - embeddable_artifact("linux", "x86_64", LEMONADE_EMBEDDABLE_FALLBACK_VERSION).is_some() + artifact.url.contains(&format!("/download/v{fallback}/")), + "url {} does not carry the pinned version", + artifact.url ); } diff --git a/crates/rocm-deps/Cargo.toml b/crates/rocm-deps/Cargo.toml new file mode 100644 index 00000000..16aa13bb --- /dev/null +++ b/crates/rocm-deps/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "rocm-deps" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish.workspace = true +# Turns the workspace-root `runtime-deps.toml` pins into constants. Declared +# explicitly so the file is not mistaken for a stray script. +build = "build.rs" + +[lints] +workspace = true + +[build-dependencies] +toml = "0.8" diff --git a/crates/rocm-deps/build.rs b/crates/rocm-deps/build.rs new file mode 100644 index 00000000..1c061d1a --- /dev/null +++ b/crates/rocm-deps/build.rs @@ -0,0 +1,77 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// +// SPDX-License-Identifier: MIT + +//! Turn the workspace-root `runtime-deps.toml` pins into Rust constants. +//! +//! Every `[runtime.]` field becomes a `pub const _: &str` +//! written to `$OUT_DIR/pins.rs`, which `src/lib.rs` includes. Generating at +//! build time (rather than committing a generated source file) means the pin +//! exists exactly once in the tree, so there is nothing to drift and no +//! `--check` gate to keep honest; a missing or malformed pin is a build error. + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::{env, fs}; + +/// Pin file, relative to this crate's manifest directory. +const PINS_FILE: &str = "../../runtime-deps.toml"; + +fn main() { + let manifest_dir = PathBuf::from( + env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is set by cargo"), + ); + let pins_path = manifest_dir.join(PINS_FILE); + println!("cargo:rerun-if-changed={}", pins_path.display()); + + let text = fs::read_to_string(&pins_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", pins_path.display())); + let generated = render(&text, &pins_path); + + let out_path = + PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is set by cargo")).join("pins.rs"); + fs::write(&out_path, generated) + .unwrap_or_else(|err| panic!("failed to write {}: {err}", out_path.display())); +} + +/// Render the constants for one pin file. Panics with a pointed message on any +/// schema violation so a bad pin fails the build instead of the runtime. +fn render(text: &str, path: &Path) -> String { + let doc: toml::Value = toml::from_str(text) + .unwrap_or_else(|err| panic!("{} is not valid TOML: {err}", path.display())); + let runtimes = doc + .get("runtime") + .and_then(toml::Value::as_table) + .unwrap_or_else(|| panic!("{} must define a [runtime] table", path.display())); + + let mut out = String::from( + "// @generated by build.rs from runtime-deps.toml -- do not edit; edit the pin file.\n", + ); + for (name, entry) in runtimes { + let fields = entry + .as_table() + .unwrap_or_else(|| panic!("[runtime.{name}] must be a table of string fields")); + for (field, value) in fields { + let literal = value + .as_str() + .unwrap_or_else(|| panic!("runtime.{name}.{field} must be a string")); + assert!( + !literal.contains(['"', '\\']), + "runtime.{name}.{field} must not contain quotes or backslashes" + ); + let ident = format!("{}_{}", const_ident(name), const_ident(field)); + writeln!( + out, + "/// `runtime.{name}.{field}` from `runtime-deps.toml`.\n\ + pub const {ident}: &str = \"{literal}\";" + ) + .expect("writing to a String cannot fail"); + } + } + out +} + +/// `rocm-abi` -> `ROCM_ABI`: upper-case, with separators folded to `_`. +fn const_ident(raw: &str) -> String { + raw.to_uppercase().replace(['-', '.'], "_") +} diff --git a/crates/rocm-deps/src/lib.rs b/crates/rocm-deps/src/lib.rs new file mode 100644 index 00000000..e8044d76 --- /dev/null +++ b/crates/rocm-deps/src/lib.rs @@ -0,0 +1,89 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// +// SPDX-License-Identifier: MIT + +//! Pinned versions of the third-party runtimes rocm-cli manages, plus the +//! helpers that derive artifact names and URLs from them. +//! +//! The pins live in the workspace-root `runtime-deps.toml` and are turned into +//! the constants below by `build.rs`. Every consumer — the engine adapters and +//! the dashboard TUI alike — derives what it needs from those constants, so a +//! runtime version is spelled exactly once in the repository and two crates +//! cannot disagree about it. + +// `pub const LEMONADE_VERSION: &str = "..."`, one per `runtime-deps.toml` field. +include!(concat!(env!("OUT_DIR"), "/pins.rs")); + +/// GitHub repository publishing the Lemonade embeddable release archives. +pub const LEMONADE_GITHUB_REPO: &str = "lemonade-sdk/lemonade"; + +/// Strip a leading `v` from a release tag (`v11.5.1` -> `11.5.1`). +#[must_use] +pub fn strip_v(tag: &str) -> &str { + tag.strip_prefix('v').unwrap_or(tag) +} + +/// Name of the Lemonade embeddable archive for a host token and extension, +/// e.g. `lemonade-embeddable-11.5.1-ubuntu-x64.tar.gz`. +#[must_use] +pub fn lemonade_archive_name(version: &str, os_arch: &str, extension: &str) -> String { + format!( + "lemonade-embeddable-{}-{os_arch}.{extension}", + strip_v(version) + ) +} + +/// Download URL of a Lemonade embeddable archive published under `v`. +#[must_use] +pub fn lemonade_download_url(version: &str, archive_name: &str) -> String { + format!( + "https://github.com/{LEMONADE_GITHUB_REPO}/releases/download/v{}/{archive_name}", + strip_v(version) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pinned_version_is_a_bare_release_number() { + assert!( + !LEMONADE_VERSION.is_empty() && !LEMONADE_VERSION.starts_with('v'), + "pin must be a bare version, got {LEMONADE_VERSION:?}" + ); + } + + #[test] + fn archive_names_and_urls_match_the_published_shape() { + let version = LEMONADE_VERSION; + let linux = lemonade_archive_name(version, "ubuntu-x64", "tar.gz"); + let windows = lemonade_archive_name(version, "windows-x64", "zip"); + assert_eq!( + linux, + format!("lemonade-embeddable-{version}-ubuntu-x64.tar.gz") + ); + assert_eq!( + windows, + format!("lemonade-embeddable-{version}-windows-x64.zip") + ); + assert_eq!( + lemonade_download_url(version, &linux), + format!( + "https://github.com/lemonade-sdk/lemonade/releases/download/v{version}/{linux}" + ) + ); + } + + #[test] + fn a_tagged_version_is_accepted_anywhere_a_version_is() { + assert_eq!( + lemonade_archive_name("v11.6.0", "ubuntu-x64", "tar.gz"), + "lemonade-embeddable-11.6.0-ubuntu-x64.tar.gz" + ); + assert_eq!( + lemonade_download_url("v11.6.0", "archive.tar.gz"), + "https://github.com/lemonade-sdk/lemonade/releases/download/v11.6.0/archive.tar.gz" + ); + } +} diff --git a/docs/engine-plugins.md b/docs/engine-plugins.md index fa707862..b2909eaa 100644 --- a/docs/engine-plugins.md +++ b/docs/engine-plugins.md @@ -32,6 +32,15 @@ The `lemonade` adapter uses Lemonade embeddable and prefers Lemonade's `llamacpp:rocm` backend, falling back to `llamacpp:vulkan` when ROCm is unsupported. rocm-cli does not use a CPU fallback for this path. +## Pinned runtime versions + +The versions of the third-party runtimes rocm-cli downloads are pinned in +`runtime-deps.toml` at the repository root — one `[runtime.]` table per +runtime. That file is the only place a runtime version is written down: +archive names, download URLs, and the dashboard's offline fallback are all +derived from it, so a bump is a one-line edit and the tree cannot end up +holding two different versions of the same runtime. + `rocm engines list` shows the exact plugin directories for the current host. The same output is available in the TUI with `/engine`. diff --git a/engines/lemonade/Cargo.toml b/engines/lemonade/Cargo.toml index f5d428ee..d9611057 100644 --- a/engines/lemonade/Cargo.toml +++ b/engines/lemonade/Cargo.toml @@ -19,6 +19,7 @@ anyhow.workspace = true clap.workspace = true flate2 = "1" rocm-core = { path = "../../crates/rocm-core" } +rocm-deps = { path = "../../crates/rocm-deps" } rocm-engine-protocol = { path = "../../crates/rocm-engine-protocol" } serde.workspace = true serde_json.workspace = true diff --git a/engines/lemonade/src/lib.rs b/engines/lemonade/src/lib.rs index 06aa8442..11bf761f 100644 --- a/engines/lemonade/src/lib.rs +++ b/engines/lemonade/src/lib.rs @@ -32,7 +32,6 @@ use std::process::{Command as ProcessCommand, Stdio}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; const ENGINE_NAME: &str = "lemonade"; -const LEMONADE_VERSION: &str = "11.5.1"; const DEFAULT_HOST: &str = "127.0.0.1"; const DEFAULT_MODEL: &str = "Qwen3-4B-Instruct-2507-GGUF"; const DEFAULT_MODEL_REPO_DIR: &str = "models--unsloth--Qwen3-4B-Instruct-2507-GGUF"; @@ -75,15 +74,26 @@ const MAX_EMBEDDABLE_SEARCH_DEPTH: usize = 4; /// stack on a pathological tree; it is far above any plausible archive layout. const MAX_COPY_RECURSION_DEPTH: usize = 64; -const EMBEDDABLE_WINDOWS_ARCHIVE_NAME: &str = "lemonade-embeddable-11.5.1-windows-x64.zip"; -const EMBEDDABLE_LINUX_ARCHIVE_NAME: &str = "lemonade-embeddable-11.5.1-ubuntu-x64.tar.gz"; -const EMBEDDABLE_WINDOWS_URL: &str = "https://github.com/lemonade-sdk/lemonade/releases/download/v11.5.1/lemonade-embeddable-11.5.1-windows-x64.zip"; -const EMBEDDABLE_LINUX_URL: &str = "https://github.com/lemonade-sdk/lemonade/releases/download/v11.5.1/lemonade-embeddable-11.5.1-ubuntu-x64.tar.gz"; +/// Digests of the pinned embeddable archives. Unlike the archive names and +/// URLs, these cannot be derived from the version in `runtime-deps.toml`: each +/// release has its own digest, so bumping the pin means recording the new pair +/// here as well. const EMBEDDABLE_WINDOWS_SHA256: &str = "50a133bbc35c4f3f8971eafef2c9fe56c4bbbfb0f1032bf8728324d8d8c8a0e1"; const EMBEDDABLE_LINUX_SHA256: &str = "bdfd3c3e5d6eda5101c8a32f36e6dd9236ceb9ab2eb66734c32628e6e86e18ac"; +/// Embeddable asset token and archive extension for the host this adapter runs +/// on. Only `windows-x64` and `ubuntu-x64` are wired up here; the release also +/// publishes `macos-arm64` and `ubuntu-arm64`, which this adapter never selects. +const fn embeddable_os_arch() -> (&'static str, &'static str) { + if runtime_is_windows() { + ("windows-x64", "zip") + } else { + ("ubuntu-x64", "tar.gz") + } +} + #[derive(Parser)] #[command(name = "rocm-engine-lemonade")] struct Cli { @@ -414,7 +424,10 @@ fn detect_response() -> DetectResponse { fn install_response(request: InstallRequest) -> Result { let paths = AppPaths::discover()?; paths.ensure()?; - eprintln!("Preparing Lemonade embeddable {LEMONADE_VERSION}..."); + eprintln!( + "Preparing Lemonade embeddable {}...", + rocm_deps::LEMONADE_VERSION + ); let env_root = request .env_root .as_deref() @@ -1017,15 +1030,16 @@ fn prepare_embeddable( reinstall: bool, ) -> Result { let root = lemonade_root(paths, env_root); - let archive_name = embeddable_archive_name(); - let archive_url = embeddable_url(); + let version = rocm_deps::LEMONADE_VERSION.to_owned(); + let archive_name = embeddable_archive_name(&version); + let archive_url = embeddable_url(&version); let downloads = root.join("downloads"); - let archive = downloads.join(archive_name); + let archive = downloads.join(&archive_name); fs::create_dir_all(&downloads)?; // Held until the archive bytes have been extracted and copied out: the // verified bytes and the extracted bytes must be the same read. let archive_guard = ensure_cached_archive( - archive_url, + &archive_url, &archive, embeddable_archive_sha256(), download_file, @@ -1055,8 +1069,8 @@ fn prepare_embeddable( ); } Ok(LemonadeInstallManifest { - env_id: format!("lemonade-embeddable-{LEMONADE_VERSION}"), - version: LEMONADE_VERSION.to_owned(), + env_id: format!("lemonade-embeddable-{version}"), + version, runtime_dir, lemond, lemonade, @@ -1274,20 +1288,13 @@ fn platform_binary_name(name: &str) -> String { } } -const fn embeddable_archive_name() -> &'static str { - if runtime_is_windows() { - EMBEDDABLE_WINDOWS_ARCHIVE_NAME - } else { - EMBEDDABLE_LINUX_ARCHIVE_NAME - } +fn embeddable_archive_name(version: &str) -> String { + let (os_arch, extension) = embeddable_os_arch(); + rocm_deps::lemonade_archive_name(version, os_arch, extension) } -const fn embeddable_url() -> &'static str { - if runtime_is_windows() { - EMBEDDABLE_WINDOWS_URL - } else { - EMBEDDABLE_LINUX_URL - } +fn embeddable_url(version: &str) -> String { + rocm_deps::lemonade_download_url(version, &embeddable_archive_name(version)) } const fn embeddable_archive_sha256() -> &'static str { @@ -3789,7 +3796,7 @@ mod tests { fn test_manifest(runtime_dir: PathBuf) -> LemonadeInstallManifest { LemonadeInstallManifest { env_id: "test".to_owned(), - version: LEMONADE_VERSION.to_owned(), + version: rocm_deps::LEMONADE_VERSION.to_owned(), runtime_dir, lemond: PathBuf::from("lemond"), lemonade: PathBuf::from("lemonade"), @@ -3816,13 +3823,16 @@ mod tests { #[test] fn embeddable_package_matches_runtime_os() { + let version = rocm_deps::LEMONADE_VERSION; + let (os_arch, extension) = embeddable_os_arch(); + let suffix = format!("{os_arch}.{extension}"); + assert!(embeddable_archive_name(version).ends_with(&suffix)); + assert!(embeddable_url(version).ends_with(&suffix)); if runtime_is_windows() { - assert!(embeddable_archive_name().ends_with("windows-x64.zip")); - assert!(embeddable_url().ends_with("windows-x64.zip")); + assert_eq!(suffix, "windows-x64.zip"); assert_eq!(platform_binary_name("lemond"), "lemond.exe"); } else { - assert!(embeddable_archive_name().ends_with("ubuntu-x64.tar.gz")); - assert!(embeddable_url().ends_with("ubuntu-x64.tar.gz")); + assert_eq!(suffix, "ubuntu-x64.tar.gz"); assert_eq!(platform_binary_name("lemond"), "lemond"); } } diff --git a/runtime-deps.toml b/runtime-deps.toml new file mode 100644 index 00000000..44885660 --- /dev/null +++ b/runtime-deps.toml @@ -0,0 +1,18 @@ +# Pinned versions of the third-party runtimes rocm-cli downloads and manages. +# +# This file is the SINGLE SOURCE OF TRUTH for those pins. Nothing else in the +# tree may spell a runtime version literal: archive names, download URLs, and +# offline fallbacks are all derived from the values below by the `rocm-deps` +# crate, whose build script turns each entry into a Rust constant. Editing a +# version here is the whole bump. +# +# Schema: one `[runtime.]` table per managed runtime, with one or more +# string fields. Every field becomes a `pub const _: &str` in +# `rocm-deps` (uppercased, `-` and `.` folded to `_`), so a runtime that needs +# more than a version — say a version plus an ABI tag — just adds a field. + +[runtime.lemonade] +# Lemonade embeddable release, published as +# `lemonade-embeddable--.{tar.gz|zip}` under the +# `v` tag of the lemonade-sdk/lemonade GitHub releases. +version = "11.5.1" From 1b7d6e6355854a0c5004655d3b1db3ea4787f04f Mon Sep 17 00:00:00 2001 From: Roman Inflianskas Date: Tue, 4 Aug 2026 12:38:24 +0000 Subject: [PATCH 4/4] fix: reinstall lemonade when the pinned version changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embeddable runtime directory is not version-scoped, so after a bump `prepare_embeddable` downloaded the new archive but skipped extraction because `lemond` was already there from the previous version. The old binaries stayed in place and were then reported as the new version. The CLI made the same mistake one layer up: it asked the adapter whether a runtime was installed and ignored which version came back, so a bump never even reached the install path. Compare versions in both places. The adapter re-extracts when the recorded version differs from the one being installed, and the CLI now requires the detected env id to be the one it asked for — which the previous commit made meaningful by deriving that id from the pin. The decision in the adapter is a pure function so the upgrade case is tested without a real archive. Also guard the pin generator against two entries whose names differ only by separator (`rocm-abi` and `rocm_abi` both fold to `ROCM_ABI`), and escape generated literals with Debug formatting instead of a hand-written rule about which characters are allowed. Signed-off-by: Roman Inflianskas Signed-off-by: Roman Inflianskas --- apps/rocm/src/main.rs | 8 +++++++- crates/rocm-deps/build.rs | 19 +++++++++++------ engines/lemonade/src/lib.rs | 41 ++++++++++++++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/apps/rocm/src/main.rs b/apps/rocm/src/main.rs index 32070d5f..9c50bd88 100644 --- a/apps/rocm/src/main.rs +++ b/apps/rocm/src/main.rs @@ -3619,8 +3619,14 @@ fn ensure_self_managed_engine_ready( }, ) .ok(); + // For a self-managing engine the runtime id *is* the env id its adapter + // reports for the pinned version, so a version bump leaves an older + // install detected-but-not-current. Requiring the ids to match makes the + // bump trigger an install instead of silently keeping the old runtime. let installed = detect.as_ref().is_some_and(|detect| { - detect.installed && detect_runtime_matches_env_root(detect, env_root.as_deref()) + detect.installed + && detect.env_id.as_deref() == Some(runtime_id.as_str()) + && detect_runtime_matches_env_root(detect, env_root.as_deref()) }); let response = if installed { None diff --git a/crates/rocm-deps/build.rs b/crates/rocm-deps/build.rs index 1c061d1a..97254126 100644 --- a/crates/rocm-deps/build.rs +++ b/crates/rocm-deps/build.rs @@ -10,6 +10,7 @@ //! exists exactly once in the tree, so there is nothing to drift and no //! `--check` gate to keep honest; a missing or malformed pin is a build error. +use std::collections::BTreeMap; use std::fmt::Write as _; use std::path::{Path, PathBuf}; use std::{env, fs}; @@ -47,6 +48,11 @@ fn render(text: &str, path: &Path) -> String { let mut out = String::from( "// @generated by build.rs from runtime-deps.toml -- do not edit; edit the pin file.\n", ); + // `a-b` and `a.b` both fold to `A_B`, so two differently spelled entries + // could generate the same constant. Reject that here rather than emitting + // a duplicate definition and a confusing compile error in the generated + // file. + let mut seen: BTreeMap = BTreeMap::new(); for (name, entry) in runtimes { let fields = entry .as_table() @@ -55,15 +61,16 @@ fn render(text: &str, path: &Path) -> String { let literal = value .as_str() .unwrap_or_else(|| panic!("runtime.{name}.{field} must be a string")); - assert!( - !literal.contains(['"', '\\']), - "runtime.{name}.{field} must not contain quotes or backslashes" - ); let ident = format!("{}_{}", const_ident(name), const_ident(field)); + let source = format!("runtime.{name}.{field}"); + if let Some(previous) = seen.insert(ident.clone(), source.clone()) { + panic!("{previous} and {source} both generate {ident}; rename one"); + } + // `{:?}` renders a correctly escaped Rust string literal, so any + // quote, backslash or control character in the pin stays valid. writeln!( out, - "/// `runtime.{name}.{field}` from `runtime-deps.toml`.\n\ - pub const {ident}: &str = \"{literal}\";" + "/// `{source}` from `runtime-deps.toml`.\npub const {ident}: &str = {literal:?};" ) .expect("writing to a String cannot fail"); } diff --git a/engines/lemonade/src/lib.rs b/engines/lemonade/src/lib.rs index 11bf761f..8fb2f8ae 100644 --- a/engines/lemonade/src/lib.rs +++ b/engines/lemonade/src/lib.rs @@ -1024,6 +1024,19 @@ fn stop_service(request: StopRequest) -> Result { Ok(StopResponse { stopped, graceful }) } +/// Whether the embeddable archive must be extracted over the runtime tree. +/// +/// Pure so the upgrade case is testable without a real archive. `installed` +/// is the version recorded for the tree already on disk, if any. +fn needs_extraction( + reinstall: bool, + installed: Option<&str>, + wanted: &str, + server_present: bool, +) -> bool { + reinstall || !server_present || installed != Some(wanted) +} + fn prepare_embeddable( paths: &AppPaths, env_root: Option<&Path>, @@ -1045,7 +1058,20 @@ fn prepare_embeddable( download_file, )?; let runtime_dir = runtime_dir_in(&root); - if reinstall || !lemond_path_in(&runtime_dir).is_file() { + // The runtime directory is not version-scoped, so a bump downloads a new + // archive into a tree that already holds `lemond` from the previous + // version. Without comparing versions the extraction would be skipped and + // the old binaries reported as the new version. + let installed_version = read_manifest(paths) + .ok() + .filter(|manifest| manifest.runtime_dir == runtime_dir) + .map(|manifest| manifest.version); + if needs_extraction( + reinstall, + installed_version.as_deref(), + &version, + lemond_path_in(&runtime_dir).is_file(), + ) { if runtime_dir.exists() { fs::remove_dir_all(&runtime_dir) .with_context(|| format!("failed to clear {}", runtime_dir.display()))?; @@ -4334,6 +4360,19 @@ mod tests { assert!(destination_child(destination, std::ffi::OsStr::new("a/b")).is_err()); } + #[test] + fn a_version_change_forces_re_extraction_over_the_existing_runtime() { + let pin = rocm_deps::LEMONADE_VERSION; + // Same version already unpacked: nothing to do. + assert!(!needs_extraction(false, Some(pin), pin, true)); + // A bump must not be silently skipped just because `lemond` exists. + assert!(needs_extraction(false, Some("10.10.0"), pin, true)); + // Nothing recorded, nothing unpacked, or an explicit reinstall. + assert!(needs_extraction(false, None, pin, true)); + assert!(needs_extraction(false, Some(pin), pin, false)); + assert!(needs_extraction(true, Some(pin), pin, true)); + } + #[test] fn lemonade_root_uses_requested_engine_root() { let paths = AppPaths {