Prechádzať zdrojové kódy

recipe-relative hook scripts (host scripts belong to recipes), bench --name, 500-line splits

Recipe-specific host scripts move out of the launcher binary. A before_serve /
after_stop entry may now be a path relative to the recipe directory:

    before_serve = [{ script = "scripts/prepare.sh" }]

The launcher reads it from the recipe dir, uploads it to each node and runs it
with NODE_RANK exported — the same contract as a built-in alias, and a built-in
wins on a name clash. Absolute paths and `..` traversal are refused (a synced
recipe repo must not be able to run an arbitrary file). This is the right home
for staging that knows NAS paths, model revisions and per-rank artifacts: it
versions with the recipe and the add-on's repo sync updates it, instead of a
launcher release per fix. tensorfold_1017_prepare -> the recipe's
scripts/prepare.sh (recipes repo).

Also: bench_target::served_model also reads TensorFold's `--name`, so bench
works on the TensorFold lanes (it insisted on --served-model-name before).

Housekeeping: before_serve tests moved to src/before_serve_tests.rs (500-line
gate), and the add-on now ships sm121/CHANGELOG.md with a per-version rule
documented in AGENTS.md.
Alexander Korolev 1 deň pred
rodič
commit
ebf461d422

+ 25 - 1
AGENTS.md

@@ -511,7 +511,24 @@ Built-in aliases:
 |-------|------|---------|
 | `stop_dgx_services` | `host-scripts/stop-dgx-services.sh` | Stop non-essential services on a DGX node (frees unified memory) |
 | `restore_dgx_services` | `host-scripts/restore-dgx-services.sh` | Restore services stopped by `stop_dgx_services` |
-| `tensorfold_1017_prepare` | `host-scripts/tensorfold-1017-prepare.sh` | Stage the TensorFold 2x 1017 lane: node-local EXL3 weights, chat template, per-rank Engram packs, CUDA prebuild (idempotent; uses `NODE_RANK`) |
+
+**Only generic helpers are built in.** Anything that knows about a specific
+recipe — NAS paths, model revisions, per-rank staging — lives next to that
+recipe in the shared recipe repo and is referenced as a **relative path**:
+
+```toml
+before_serve = [{ script = "scripts/prepare.sh" }]   # <recipe dir>/scripts/prepare.sh
+```
+
+The launcher reads it from the recipe directory, uploads it to each node
+(`/tmp/sm121-hook-<sanitized>.sh`) and runs it with `NODE_RANK` exported — the
+same contract as a built-in alias, and a built-in alias wins on a name clash.
+Absolute paths and `..` traversal are refused (a synced recipe repo must not be
+able to run an arbitrary file). Recipe scripts are versioned with the recipe,
+so the add-on's repo sync updates them without a launcher release; a built-in
+needs an `include_str!` entry plus a release. `tensorfold_1017_prepare` was a
+per-recipe staging script baked into the binary until 2026-10-21 — it now lives
+at `recipes/dsv41-flash-1017-2x-tensorfold/scripts/prepare.sh`.
 
 Built-in scripts run with **`NODE_RANK` exported** (`sudo NODE_RANK=<n> bash
 <remote>`), so a hook can stage per-rank artifacts. Helper files a hook needs
@@ -916,6 +933,13 @@ manifest) on push to main via OIDC — no `docker login` anywhere (the
 GHCR package is created private: set it public once so HA pulls
 anonymously). A launcher change does not trigger the add-on CI
 (cross-repo) — re-run it (workflow_dispatch) to ship a new binary.
+**Release notes:** the add-on carries `sm121/CHANGELOG.md` — HA shows the
+section matching the installed version in the store's "Release notes" panel.
+**Every version bump adds its own `## <version>` section** (same version string
+as `config.yaml`, newest first) describing what an operator notices: new
+dashboard UI, recipe/hook format changes, gateway or power behaviour. The
+commit message is not a substitute — nothing in HA reads it.
+
 Local dev: `make addon-image` (rsyncs this repo into the add-on store repo's
 `launcher-src/` + docker build) + `scripts/smoke-addon.sh` (entrypoint +
 `file://` recipe repo + curl /api/state). The entrypoint writes the

+ 0 - 140
host-scripts/tensorfold-1017-prepare.sh

@@ -1,140 +0,0 @@
-#!/usr/bin/env bash
-# tensorfold-1017-prepare — host-side staging for the TensorFold 2x 1017 lane.
-#
-# Idempotent: every step is skipped when its output already exists. Runs on
-# each launch node before the containers start (recipe `before_serve`), with
-# NODE_RANK exported by the launcher.
-#
-# Everything here works WITHOUT the image: the launcher runs pre-serve hooks
-# *before* it builds/syncs the image, so the helper files (Engram packer, chat
-# template, CUDA prebuild inputs) are published to the NAS by the recipe image's
-# build.sh. The one image-dependent step (CUDA prebuild) is skipped when the
-# image is not on this node yet and simply runs on the next serve.
-#
-# Steps
-#   1. node-local copy of the EXL3 2.9bpw checkpoint (EXL3 streams weights on
-#      every decode step — reading them off the NAS costs most of the decode);
-#   2. the chat template the TensorFold HTTP app requires at init (the EXL3
-#      checkpoint carries none);
-#   3. the per-rank Engram packs (layers 1 + 14, ~47 GiB each) built from the
-#      official 1017 shards 47/48 — rank r owns hash heads [12r, 12r+12);
-#   4. the CUDA extension prebuild (CPU-only, 18/18 builders) when the image is
-#      already present.
-#
-# Overrides (env): TF1017_ROOT (default /home/kilork/ns/1017-tf),
-# TF1017_NAS (default /mnt/nas/ai/huggingface/hub), TF1017_IMAGE,
-# TF1017_SKIP_WEIGHTS=1, TF1017_SKIP_PACK=1, TF1017_SKIP_PREBUILD=1.
-set -euo pipefail
-
-ROOT="${TF1017_ROOT:-/home/kilork/ns/1017-tf}"
-NAS="${TF1017_NAS:-/mnt/nas/ai/huggingface/hub}"
-PUB="${TF1017_PUB:-/mnt/nas/ai/sm121/dsv41-tf-1017}"
-IMG="${TF1017_IMAGE:-dsv41-tf-1017:v1}"
-RANK="${NODE_RANK:-0}"
-# Pins are only a fallback: the HF cache's `refs/main` is the truth (a
-# re-cut/re-download moves the snapshot, and a hardcoded rev then points at a
-# directory that no longer exists — exactly what broke the 2026-10-21 serve).
-pin_or_ref() { # <hub dir> <pinned rev>
-    local d="$NAS/$1" ref
-    ref=$(cat "$d/refs/main" 2>/dev/null || true)
-    if [[ -n "$ref" && -d "$d/snapshots/$ref" ]]; then echo "$ref"; else echo "$2"; fi
-}
-REV="${TF1017_MODEL_REV:-$(pin_or_ref models--LibertAIDAI--DeepSeek-V4.1-Flash-1017-EXL3-2.9bpw afc7aa0aab98ee735c0f54e0a122019af1ac08f1)}"
-EREV="${TF1017_ENGRAM_REV:-$(pin_or_ref models--deepseek-ai--DeepSeek-V4.1-Flash-1017 1bae0e1088993b9b82de08f59de5be5a34e42170)}"
-
-WBUNDLE="$NAS/models--LibertAIDAI--DeepSeek-V4.1-Flash-1017-EXL3-2.9bpw/snapshots/$REV"
-EBUNDLE="$NAS/models--deepseek-ai--DeepSeek-V4.1-Flash-1017/snapshots/$EREV"
-[[ -d "$WBUNDLE" ]] || { echo "[tf1017] FATAL: no checkpoint snapshot at $WBUNDLE" >&2; exit 1; }
-[[ -d "$EBUNDLE" ]] || { echo "[tf1017] FATAL: no Engram source snapshot at $EBUNDLE" >&2; exit 1; }
-echo "[tf1017] checkpoint rev $REV, Engram rev $EREV"
-CKPT="$ROOT/models/checkpoint"
-PACKED="$ROOT/engram-packed-1017"
-CACHE="$ROOT/cache"
-HELP="$ROOT/helpers"
-
-mkdir -p "$CKPT" "$PACKED" "$CACHE" "$HELP" "$ROOT/state"
-
-echo "[tf1017] rank $RANK — root $ROOT"
-
-# 0. helper files (published to the NAS by the image's build.sh) -------------
-for f in pack_engram.py prebuild_ext.py prebuild-offline.py pfdense-table.json \
-         chat_template.jinja; do
-    if [[ ! -s "$HELP/$f" ]]; then
-        if [[ -s "$PUB/$f" ]]; then
-            cp -f "$PUB/$f" "$HELP/$f"
-        elif docker image inspect "$IMG" >/dev/null 2>&1; then
-            docker run --rm --network none --entrypoint cat "$IMG" "/opt/sm121-helpers/$f" > "$HELP/$f"
-        else
-            echo "[tf1017] FATAL: $f not published at $PUB and image $IMG absent" >&2
-            exit 1
-        fi
-    fi
-done
-
-# 1. weights -----------------------------------------------------------------
-if [[ "${TF1017_SKIP_WEIGHTS:-0}" != "1" ]]; then
-    have=$(find "$CKPT" -maxdepth 1 -name '*.safetensors' | wc -l)
-    if [[ "$have" -lt 43 ]]; then
-        echo "[tf1017] copying EXL3 checkpoint from the NAS ($have/43 shards present)"
-        tar -chf - -C "$WBUNDLE" . | tar -xf - -C "$CKPT"
-    fi
-    have=$(find "$CKPT" -maxdepth 1 -name '*.safetensors' | wc -l)
-    [[ "$have" -ge 43 ]] || { echo "[tf1017] FATAL: checkpoint incomplete ($have/43)" >&2; exit 1; }
-fi
-
-# 2. chat template -----------------------------------------------------------
-[[ -s "$CKPT/chat_template.jinja" ]] || cp "$HELP/chat_template.jinja" "$CKPT/chat_template.jinja"
-
-# 3. per-rank Engram packs (host python + numpy; no image needed) ------------
-if [[ "${TF1017_SKIP_PACK:-0}" != "1" ]]; then
-    if [[ ! -s "$PACKED/engram-l14-r${RANK}of2.bin" ]]; then
-        echo "[tf1017] packing Engram tables for rank $RANK (~94 GiB, reads 189 GiB from the NAS)"
-        if [[ "${TF1017_PACK_IN_IMAGE:-1}" = "1" ]] && docker image inspect "$IMG" >/dev/null 2>&1; then
-            # Prefer the image: it has numpy + the exact python the packer was
-            # written against. Host python is PEP-668 managed on dgx7/8
-            # (`error: externally-managed-environment`) — no host installs.
-            # Mount the whole repo dir (`snapshots/` + `blobs/`), not just the
-            # snapshot: the snapshot files are symlinks into the hub's blobs,
-            # so a snapshot-only mount makes model.safetensors.index.json
-            # dangle inside the container.
-            # Mount the whole hub: snapshot files are symlinks to the
-            # per-model blobs, which are themselves symlinks to hub-level
-            # blobs (huggingface_hub >= 0.34 layout) — anything less leaves
-            # model.safetensors.index.json dangling in the container.
-            docker run --rm --network none --entrypoint python3 \
-                -v "$NAS":/hub:ro -v "$CKPT":/ckpt:ro -v "$PACKED":/out \
-                -v "$HELP/pack_engram.py":/pack_engram.py:ro \
-                "$IMG" /pack_engram.py --src "/hub/${EBUNDLE#"$NAS"/}" \
-                --config /ckpt/config.json --out /out --rank "$RANK"
-        else
-            python3 -c 'import numpy' 2>/dev/null \
-                || python3 -m pip install --user --quiet --break-system-packages numpy \
-                || { echo "[tf1017] FATAL: numpy missing for the Engram packer and pip refused (no image on this node yet)" >&2; exit 1; }
-            python3 "$HELP/pack_engram.py" \
-                --src "$EBUNDLE" --config "$CKPT/config.json" \
-                --out "$PACKED" --rank "$RANK"
-        fi
-    fi
-    [[ -s "$PACKED/engram-l1-r${RANK}of2.bin" && -s "$PACKED/engram-l14-r${RANK}of2.bin" ]] \
-        || { echo "[tf1017] FATAL: Engram packs missing for rank $RANK" >&2; exit 1; }
-fi
-
-# 4. CUDA extension prebuild (needs the image; skipped on a first serve) -----
-if [[ "${TF1017_SKIP_PREBUILD:-0}" != "1" && ! -s "$CACHE/pfdense-table.json" ]]; then
-    if docker image inspect "$IMG" >/dev/null 2>&1; then
-        echo "[tf1017] prebuilding CUDA extensions (CPU-only)"
-        docker run --rm --network none --memory 8g --cpus 4 \
-            -e CUDA_VISIBLE_DEVICES= -e TORCH_CUDA_ARCH_LIST=12.1 -e MAX_JOBS=4 \
-            -e TORCH_EXTENSIONS_DIR=/cache/torch_extensions \
-            -v "$CACHE":/cache \
-            -v "$HELP/prebuild-offline.py":/offline.py:ro \
-            -v "$HELP/prebuild_ext.py":/prebuild.py:ro \
-            -v "$HELP/pfdense-table.json":/table.json:ro \
-            --entrypoint bash "$IMG" \
-            -c 'python /offline.py && cp /table.json /cache/pfdense-table.json'
-    else
-        echo "[tf1017] image $IMG not on this node yet — extensions will compile at first start"
-    fi
-fi
-
-echo "[tf1017] rank $RANK ready"

+ 68 - 275
src/before_serve.rs

@@ -22,6 +22,7 @@ pub async fn run(
     config: &Config,
     cluster: &Cluster,
     recipe: &Recipe,
+    recipe_name: &str,
     skip: bool,
     dry_run: bool,
 ) -> Result<()> {
@@ -41,6 +42,13 @@ pub async fn run(
         cluster.nodes.len()
     );
 
+    // A `{ script = "scripts/prepare.sh" }` hook is recipe-relative: resolve
+    // the recipe's own directory up front (it lives in the shared recipe repo,
+    // next to recipe.toml — not in this binary).
+    let recipe_dir = crate::config::recipe_dir(recipe_name, &config.recipe_sources)
+        .await
+        .ok();
+
     // Run all commands for one node sequentially (so order matters per
     // node), and the nodes in parallel.
     let mut handles = Vec::with_capacity(cluster.nodes.len());
@@ -48,6 +56,7 @@ pub async fn run(
         let config = config.clone();
         let node = node.clone();
         let cmds = cmds.clone();
+        let dir = recipe_dir.clone();
         let dry = dry_run;
         handles.push(crate::locale::spawn_with_locale(async move {
             (
@@ -57,7 +66,15 @@ pub async fn run(
                 // per-rank staging hook must see the same 0..N-1 index —
                 // otherwise dgx7/dgx8 in a 4-node cluster stage rank 2/3
                 // packs for a 2-rank engine (2026-10-21).
-                run_for_node(&config, &node, local_rank as u32, &cmds, dry).await,
+                run_for_node(
+                    &config,
+                    &node,
+                    local_rank as u32,
+                    &cmds,
+                    dir.as_deref(),
+                    dry,
+                )
+                .await,
             )
         }));
     }
@@ -111,6 +128,7 @@ async fn run_for_node(
     node: &crate::config::Node,
     local_rank: u32,
     cmds: &[(&'static str, PreServeCommand)],
+    recipe_dir: Option<&std::path::Path>,
     dry_run: bool,
 ) -> Result<()> {
     for (origin, cmd) in cmds {
@@ -120,20 +138,29 @@ async fn run_for_node(
         // command (`Bare`/`Configured`) runs as-is.
         let (label_cmd, ssh_cmd) = match cmd {
             PreServeCommand::Script { script, .. } => {
-                let body = crate::builtin_scripts::resolve(script).ok_or_else(|| {
-                    anyhow::anyhow!(
-                        "Unknown built-in script alias '{}' on node {}. Known: {}",
-                        script,
-                        node.name,
-                        crate::builtin_scripts::known_aliases()
-                    )
-                })?;
-                let remote = format!("/tmp/sm121-builtin-{}.sh", script);
-                crate::ssh::upload_script(config, node, body, &remote, dry_run).await?;
+                // Built-in alias (generic, cluster-wide helpers baked into the
+                // binary) or a path relative to the recipe directory (the
+                // recipe-specific staging script, shipped in the recipe repo so
+                // it evolves with the recipe instead of with a release).
+                let (body, tag) = match crate::builtin_scripts::resolve(script) {
+                    Some(body) => (body.to_string(), script.clone()),
+                    None => (
+                        read_recipe_script(script, recipe_dir, &node.name).await?,
+                        script.clone(),
+                    ),
+                };
+                let remote = format!(
+                    "/tmp/sm121-hook-{}.sh",
+                    script
+                        .chars()
+                        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
+                        .collect::<String>()
+                );
+                crate::ssh::upload_script(config, node, &body, &remote, dry_run).await?;
                 // `NODE_RANK` lets a built-in stage per-rank artifacts (e.g. the
                 // TensorFold Engram packs) without inventing a second mechanism.
                 (
-                    format!("script:{script}"),
+                    format!("script:{tag}"),
                     format!("sudo NODE_RANK={local_rank} bash {remote}"),
                 )
             }
@@ -192,268 +219,34 @@ fn truncate(s: &str, max: usize) -> String {
     }
 }
 
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::config::{Node, PreServeCommand};
-    use crate::pre_serve::VenvSetup;
-    use std::collections::HashMap;
-
-    fn test_cluster() -> Cluster {
-        Cluster {
-            name: "c1".to_string(),
-            master_ib_ip: Some("1.2.3.4".to_string()),
-            nodes: vec![
-                Node {
-                    name: "n0".to_string(),
-                    ssh_ip: "10.0.0.1".to_string(),
-                    ib_ip: Some("1.2.3.4".to_string()),
-                    rank: 0,
-                    host_key: None,
-                    entity: None,
-                },
-                Node {
-                    name: "n1".to_string(),
-                    ssh_ip: "10.0.0.2".to_string(),
-                    ib_ip: Some("1.2.3.5".to_string()),
-                    rank: 1,
-                    host_key: None,
-                    entity: None,
-                },
-            ],
-            extra_entities: vec![],
-        }
-    }
-
-    fn test_config(before_serve: HashMap<String, crate::config::BeforeServeTable>) -> Config {
-        Config {
-            ssh_user: "u".to_string(),
-            ssh_key_path: "/k".into(),
-            clusters: vec![test_cluster()],
-            nccl: HashMap::new(),
-            hf_cache_dir: None,
-            init_port: 25000,
-            before_serve,
-            exec_aliases: HashMap::new(),
-            home_assistant: None,
-            mikrotik: None,
-            global_mods_dir: None,
-            drop_caches: true,
-            mount_aliases: HashMap::new(),
-            recipe_sources: Vec::new(),
-            recipe_repos: Vec::new(),
-            docker_image_sources: Vec::new(),
-            cluster_sources: Vec::new(),
-            registry: None,
-        }
-    }
-
-    #[test]
-    fn truncate_keeps_short_string_unchanged() {
-        assert_eq!(truncate("short", 120), "short");
-        assert_eq!(truncate("", 120), "");
-    }
-
-    #[test]
-    fn truncate_shortens_to_char_boundary() {
-        // 10 chars, cap at 4 → first 4 chars + ellipsis
-        let out = truncate("abcdefghij", 4);
-        assert_eq!(out, "abcd…");
-    }
-
-    #[test]
-    fn truncate_does_not_panic_on_multibyte() {
-        // Each 'é' is 2 bytes; a byte slice `&s[..3]` would land mid-char and
-        // panic. The char-based impl must not. (Regression for C-2.)
-        let s = "éééééé"; // 6 chars, 12 bytes
-        assert_eq!(truncate(s, 3), "ééé…");
-        // cap exactly at a multibyte boundary must also be safe
-        assert_eq!(truncate(s, 4), "éééé…");
-    }
-
-    #[test]
-    fn truncate_cjk_chars() {
-        // 3-byte CJK: byte-slice at any non-multiple-of-3 would panic.
-        let s = "你好世界你好";
-        assert_eq!(truncate(s, 2), "你好…");
-    }
-
-    #[test]
-    fn combined_runs_global_then_cluster_then_recipe() {
-        let mut bs = HashMap::new();
-        bs.insert(
-            "c1".to_string(),
-            crate::config::BeforeServeTable {
-                commands: vec![PreServeCommand::Bare("cluster_cmd".into())],
-            },
-        );
-        bs.insert(
-            "*".to_string(),
-            crate::config::BeforeServeTable {
-                commands: vec![PreServeCommand::Bare("global_cmd".into())],
-            },
-        );
-        let cfg = test_config(bs);
-        let recipe = Recipe {
-            before_serve: vec![PreServeCommand::Bare("recipe_cmd".into())],
-            ..test_recipe()
-        };
-
-        let cmds = combined_commands(&cfg, &test_cluster(), &recipe);
-        let names: Vec<&str> = cmds.iter().map(|(_, c)| c.command()).collect();
-        let origins: Vec<&str> = cmds.iter().map(|(o, _)| *o).collect();
-        assert_eq!(names, vec!["global_cmd", "cluster_cmd", "recipe_cmd"]);
-        assert_eq!(origins, vec!["global", "cluster", "recipe"]);
-    }
-
-    #[test]
-    fn combined_global_applies_to_unlisted_cluster() {
-        let mut bs = HashMap::new();
-        bs.insert(
-            "*".to_string(),
-            crate::config::BeforeServeTable {
-                commands: vec![PreServeCommand::Bare("global_cmd".into())],
-            },
-        );
-        // Cluster "c1" has no specific entry — only the global hook runs.
-        let cfg = test_config(bs);
-        let recipe = Recipe {
-            before_serve: vec![],
-            after_stop: vec![],
-            solo: None,
-            ..test_recipe()
-        };
-
-        let cmds = combined_commands(&cfg, &test_cluster(), &recipe);
-        let names: Vec<&str> = cmds.iter().map(|(_, c)| c.command()).collect();
-        assert_eq!(names, vec!["global_cmd"]);
-    }
-
-    #[test]
-    fn combined_orders_cluster_then_recipe() {
-        let mut bs = HashMap::new();
-        bs.insert(
-            "c1".to_string(),
-            crate::config::BeforeServeTable {
-                commands: vec![PreServeCommand::Bare("cluster_cmd_1".into())],
-            },
+/// Read a recipe-relative hook script (`{ script = "scripts/prepare.sh" }`).
+/// The path must stay inside the recipe directory — no absolute paths, no
+/// `..` traversal — so a synced recipe repo cannot make the launcher run an
+/// arbitrary file.
+async fn read_recipe_script(
+    script: &str,
+    recipe_dir: Option<&std::path::Path>,
+    node: &str,
+) -> Result<String> {
+    let Some(dir) = recipe_dir else {
+        anyhow::bail!(
+            "hook script '{script}' is not a built-in alias ({}) and the recipe              directory could not be resolved on node {node}",
+            crate::builtin_scripts::known_aliases()
         );
-        let cfg = test_config(bs);
-        let recipe = Recipe {
-            before_serve: vec![
-                PreServeCommand::Bare("recipe_cmd_1".into()),
-                PreServeCommand::Bare("recipe_cmd_2".into()),
-            ],
-            ..test_recipe()
-        };
-
-        let cmds = combined_commands(&cfg, &test_cluster(), &recipe);
-        let names: Vec<&str> = cmds.iter().map(|(_, c)| c.command()).collect();
-        let origins: Vec<&str> = cmds.iter().map(|(o, _)| *o).collect();
-        assert_eq!(names, vec!["cluster_cmd_1", "recipe_cmd_1", "recipe_cmd_2"]);
-        assert_eq!(origins, vec!["cluster", "recipe", "recipe"]);
-    }
-
-    #[test]
-    fn combined_empty_when_unconfigured() {
-        let cfg = test_config(HashMap::new());
-        let recipe = Recipe {
-            before_serve: vec![],
-            after_stop: vec![],
-            solo: None,
-            ..test_recipe()
-        };
-        assert!(combined_commands(&cfg, &test_cluster(), &recipe).is_empty());
-    }
-
-    fn test_recipe() -> Recipe {
-        Recipe {
-            build_profile: None,
-            parent_model: None,
-            archived: false,
-            engine: "vllm".to_string(),
-            name: "Test".to_string(),
-            docker_image: String::new(),
-            model: "x".to_string(),
-            port: 30000,
-            required_nodes: None,
-            pipeline_parallel_size: None,
-            tensor_parallel_size: 1,
-            args: crate::pre_serve::RecipeArgs::default(),
-            cold_args: crate::pre_serve::RecipeArgs::default(),
-            env: HashMap::new(),
-            cluster: None,
-            entrypoint: vec![],
-            distributed_backend: Some(crate::config::DistributedBackend::Mp),
-            before_serve: vec![],
-            after_stop: vec![],
-            solo: None,
-            deploy: crate::config::DeployTarget::Docker,
-            venv: None,
-            venv_setup: VenvSetup::default(),
-            venv_setup_script: None,
-            mods: Default::default(),
-            sync_image: true,
-            docker_image_dir: None,
-            shm_size: String::new(),
-            devices: vec![],
-            memory_limit: None,
-            cpus: None,
-            extra_mounts: vec![],
-            extra_mounts_manifest: None,
-            build: Default::default(),
-            composed_model: None,
-            staging: vec![],
-            dgpp: None,
-        }
-    }
-
-    // ── Deserialization tests (the ones that live in this module) ──
-
-    #[test]
-    fn parses_cluster_level_before_serve() {
-        let toml_str = r#"
-ssh_user = "u"
-ssh_key_path = "/k"
-
-[[clusters]]
-name = "c1"
-master_ib_ip = "1.1.1.1"
-nodes = [
-    { name = "n0", ssh_ip = "2.2.2.2", ib_ip = "1.1.1.1", rank = 0 },
-]
-
-[before_serve."c1"]
-commands = [
-    "sudo nvidia-smi -lgc 300,2300",
-    { command = "sudo nvidia-smi -pm 1", on_failure = "warn" },
-]
-"#;
-        let cfg: Config = toml::from_str(toml_str).unwrap();
-        assert!(cfg.before_serve.contains_key("c1"));
-        let list = &cfg.before_serve["c1"].commands;
-        assert_eq!(list.len(), 2);
-        assert_eq!(list[0].command(), "sudo nvidia-smi -lgc 300,2300");
-        assert_eq!(list[0].on_failure(), OnFailure::Abort); // default
-        assert_eq!(list[1].command(), "sudo nvidia-smi -pm 1");
-        assert_eq!(list[1].on_failure(), OnFailure::Warn);
-    }
-
-    #[test]
-    fn parses_recipe_level_before_serve() {
-        let toml_str = r#"
-name = "Demo"
-model = "x"
-before_serve = [
-    "echo a",
-    { command = "echo b", on_failure = "continue" },
-]
-"#;
-        let r: Recipe = toml::from_str(toml_str).unwrap();
-        assert_eq!(r.before_serve.len(), 2);
-        assert_eq!(r.before_serve[0].command(), "echo a");
-        assert_eq!(r.before_serve[0].on_failure(), OnFailure::Abort);
-        assert_eq!(r.before_serve[1].command(), "echo b");
-        assert_eq!(r.before_serve[1].on_failure(), OnFailure::Continue);
-    }
+    };
+    if script.starts_with('/') || script.split('/').any(|c| c == "..") {
+        anyhow::bail!("hook script '{script}' must be a relative path inside the recipe dir");
+    }
+    let path = dir.join(script);
+    tokio::fs::read_to_string(&path).await.map_err(|e| {
+        anyhow::anyhow!(
+            "hook script '{script}' not found in {} ({e}); it is not a built-in alias either              (known: {})",
+            dir.display(),
+            crate::builtin_scripts::known_aliases()
+        )
+    })
 }
+
+#[cfg(test)]
+#[path = "before_serve_tests.rs"]
+mod tests;

+ 301 - 0
src/before_serve_tests.rs

@@ -0,0 +1,301 @@
+//! Tests for `before_serve` (split at the 500-line cap).
+
+use super::*;
+use crate::config::{Node, PreServeCommand};
+use crate::pre_serve::VenvSetup;
+use std::collections::HashMap;
+
+fn test_cluster() -> Cluster {
+    Cluster {
+        name: "c1".to_string(),
+        master_ib_ip: Some("1.2.3.4".to_string()),
+        nodes: vec![
+            Node {
+                name: "n0".to_string(),
+                ssh_ip: "10.0.0.1".to_string(),
+                ib_ip: Some("1.2.3.4".to_string()),
+                rank: 0,
+                host_key: None,
+                entity: None,
+            },
+            Node {
+                name: "n1".to_string(),
+                ssh_ip: "10.0.0.2".to_string(),
+                ib_ip: Some("1.2.3.5".to_string()),
+                rank: 1,
+                host_key: None,
+                entity: None,
+            },
+        ],
+        extra_entities: vec![],
+    }
+}
+
+fn test_config(before_serve: HashMap<String, crate::config::BeforeServeTable>) -> Config {
+    Config {
+        ssh_user: "u".to_string(),
+        ssh_key_path: "/k".into(),
+        clusters: vec![test_cluster()],
+        nccl: HashMap::new(),
+        hf_cache_dir: None,
+        init_port: 25000,
+        before_serve,
+        exec_aliases: HashMap::new(),
+        home_assistant: None,
+        mikrotik: None,
+        global_mods_dir: None,
+        drop_caches: true,
+        mount_aliases: HashMap::new(),
+        recipe_sources: Vec::new(),
+        recipe_repos: Vec::new(),
+        docker_image_sources: Vec::new(),
+        cluster_sources: Vec::new(),
+        registry: None,
+    }
+}
+
+#[test]
+fn truncate_keeps_short_string_unchanged() {
+    assert_eq!(truncate("short", 120), "short");
+    assert_eq!(truncate("", 120), "");
+}
+
+#[test]
+fn truncate_shortens_to_char_boundary() {
+    // 10 chars, cap at 4 → first 4 chars + ellipsis
+    let out = truncate("abcdefghij", 4);
+    assert_eq!(out, "abcd…");
+}
+
+#[test]
+fn truncate_does_not_panic_on_multibyte() {
+    // Each 'é' is 2 bytes; a byte slice `&s[..3]` would land mid-char and
+    // panic. The char-based impl must not. (Regression for C-2.)
+    let s = "éééééé"; // 6 chars, 12 bytes
+    assert_eq!(truncate(s, 3), "ééé…");
+    // cap exactly at a multibyte boundary must also be safe
+    assert_eq!(truncate(s, 4), "éééé…");
+}
+
+#[test]
+fn truncate_cjk_chars() {
+    // 3-byte CJK: byte-slice at any non-multiple-of-3 would panic.
+    let s = "你好世界你好";
+    assert_eq!(truncate(s, 2), "你好…");
+}
+
+#[test]
+fn combined_runs_global_then_cluster_then_recipe() {
+    let mut bs = HashMap::new();
+    bs.insert(
+        "c1".to_string(),
+        crate::config::BeforeServeTable {
+            commands: vec![PreServeCommand::Bare("cluster_cmd".into())],
+        },
+    );
+    bs.insert(
+        "*".to_string(),
+        crate::config::BeforeServeTable {
+            commands: vec![PreServeCommand::Bare("global_cmd".into())],
+        },
+    );
+    let cfg = test_config(bs);
+    let recipe = Recipe {
+        before_serve: vec![PreServeCommand::Bare("recipe_cmd".into())],
+        ..test_recipe()
+    };
+
+    let cmds = combined_commands(&cfg, &test_cluster(), &recipe);
+    let names: Vec<&str> = cmds.iter().map(|(_, c)| c.command()).collect();
+    let origins: Vec<&str> = cmds.iter().map(|(o, _)| *o).collect();
+    assert_eq!(names, vec!["global_cmd", "cluster_cmd", "recipe_cmd"]);
+    assert_eq!(origins, vec!["global", "cluster", "recipe"]);
+}
+
+#[test]
+fn combined_global_applies_to_unlisted_cluster() {
+    let mut bs = HashMap::new();
+    bs.insert(
+        "*".to_string(),
+        crate::config::BeforeServeTable {
+            commands: vec![PreServeCommand::Bare("global_cmd".into())],
+        },
+    );
+    // Cluster "c1" has no specific entry — only the global hook runs.
+    let cfg = test_config(bs);
+    let recipe = Recipe {
+        before_serve: vec![],
+        after_stop: vec![],
+        solo: None,
+        ..test_recipe()
+    };
+
+    let cmds = combined_commands(&cfg, &test_cluster(), &recipe);
+    let names: Vec<&str> = cmds.iter().map(|(_, c)| c.command()).collect();
+    assert_eq!(names, vec!["global_cmd"]);
+}
+
+#[test]
+fn combined_orders_cluster_then_recipe() {
+    let mut bs = HashMap::new();
+    bs.insert(
+        "c1".to_string(),
+        crate::config::BeforeServeTable {
+            commands: vec![PreServeCommand::Bare("cluster_cmd_1".into())],
+        },
+    );
+    let cfg = test_config(bs);
+    let recipe = Recipe {
+        before_serve: vec![
+            PreServeCommand::Bare("recipe_cmd_1".into()),
+            PreServeCommand::Bare("recipe_cmd_2".into()),
+        ],
+        ..test_recipe()
+    };
+
+    let cmds = combined_commands(&cfg, &test_cluster(), &recipe);
+    let names: Vec<&str> = cmds.iter().map(|(_, c)| c.command()).collect();
+    let origins: Vec<&str> = cmds.iter().map(|(o, _)| *o).collect();
+    assert_eq!(names, vec!["cluster_cmd_1", "recipe_cmd_1", "recipe_cmd_2"]);
+    assert_eq!(origins, vec!["cluster", "recipe", "recipe"]);
+}
+
+#[test]
+fn combined_empty_when_unconfigured() {
+    let cfg = test_config(HashMap::new());
+    let recipe = Recipe {
+        before_serve: vec![],
+        after_stop: vec![],
+        solo: None,
+        ..test_recipe()
+    };
+    assert!(combined_commands(&cfg, &test_cluster(), &recipe).is_empty());
+}
+
+fn test_recipe() -> Recipe {
+    Recipe {
+        build_profile: None,
+        parent_model: None,
+        archived: false,
+        engine: "vllm".to_string(),
+        name: "Test".to_string(),
+        docker_image: String::new(),
+        model: "x".to_string(),
+        port: 30000,
+        required_nodes: None,
+        pipeline_parallel_size: None,
+        tensor_parallel_size: 1,
+        args: crate::pre_serve::RecipeArgs::default(),
+        cold_args: crate::pre_serve::RecipeArgs::default(),
+        env: HashMap::new(),
+        cluster: None,
+        entrypoint: vec![],
+        distributed_backend: Some(crate::config::DistributedBackend::Mp),
+        before_serve: vec![],
+        after_stop: vec![],
+        solo: None,
+        deploy: crate::config::DeployTarget::Docker,
+        venv: None,
+        venv_setup: VenvSetup::default(),
+        venv_setup_script: None,
+        mods: Default::default(),
+        sync_image: true,
+        docker_image_dir: None,
+        shm_size: String::new(),
+        devices: vec![],
+        memory_limit: None,
+        cpus: None,
+        extra_mounts: vec![],
+        extra_mounts_manifest: None,
+        build: Default::default(),
+        composed_model: None,
+        staging: vec![],
+        dgpp: None,
+    }
+}
+
+// ── Deserialization tests (the ones that live in this module) ──
+
+#[test]
+fn parses_cluster_level_before_serve() {
+    let toml_str = r#"
+ssh_user = "u"
+ssh_key_path = "/k"
+
+[[clusters]]
+name = "c1"
+master_ib_ip = "1.1.1.1"
+nodes = [
+{ name = "n0", ssh_ip = "2.2.2.2", ib_ip = "1.1.1.1", rank = 0 },
+]
+
+[before_serve."c1"]
+commands = [
+"sudo nvidia-smi -lgc 300,2300",
+{ command = "sudo nvidia-smi -pm 1", on_failure = "warn" },
+]
+"#;
+    let cfg: Config = toml::from_str(toml_str).unwrap();
+    assert!(cfg.before_serve.contains_key("c1"));
+    let list = &cfg.before_serve["c1"].commands;
+    assert_eq!(list.len(), 2);
+    assert_eq!(list[0].command(), "sudo nvidia-smi -lgc 300,2300");
+    assert_eq!(list[0].on_failure(), OnFailure::Abort); // default
+    assert_eq!(list[1].command(), "sudo nvidia-smi -pm 1");
+    assert_eq!(list[1].on_failure(), OnFailure::Warn);
+}
+
+#[test]
+fn parses_recipe_level_before_serve() {
+    let toml_str = r#"
+name = "Demo"
+model = "x"
+before_serve = [
+"echo a",
+{ command = "echo b", on_failure = "continue" },
+]
+"#;
+    let r: Recipe = toml::from_str(toml_str).unwrap();
+    assert_eq!(r.before_serve.len(), 2);
+    assert_eq!(r.before_serve[0].command(), "echo a");
+    assert_eq!(r.before_serve[0].on_failure(), OnFailure::Abort);
+    assert_eq!(r.before_serve[1].command(), "echo b");
+    assert_eq!(r.before_serve[1].on_failure(), OnFailure::Continue);
+}
+
+/// A recipe-relative hook script is read from the recipe dir; absolute
+/// paths and `..` traversal are refused (a synced recipe repo must not be
+/// able to run an arbitrary file on a node).
+#[tokio::test]
+async fn recipe_scripts_resolve_inside_the_recipe_dir() {
+    let dir = std::env::temp_dir().join(format!("sm121-hook-{}", std::process::id()));
+    tokio::fs::create_dir_all(dir.join("scripts"))
+        .await
+        .unwrap();
+    tokio::fs::write(dir.join("scripts/prepare.sh"), "echo hi\n")
+        .await
+        .unwrap();
+
+    let body = read_recipe_script("scripts/prepare.sh", Some(&dir), "n0")
+        .await
+        .expect("recipe script reads");
+    assert_eq!(body, "echo hi\n");
+
+    let abs = read_recipe_script("/etc/passwd", Some(&dir), "n0").await;
+    assert!(abs.is_err(), "absolute paths are refused");
+    let trav = read_recipe_script("scripts/../../etc/passwd", Some(&dir), "n0").await;
+    assert!(trav.is_err(), "`..` traversal is refused");
+    let missing = read_recipe_script("scripts/nope.sh", Some(&dir), "n0").await;
+    assert!(missing.is_err(), "missing file names the built-in aliases");
+    let no_dir = read_recipe_script("scripts/prepare.sh", None, "n0").await;
+    assert!(no_dir.is_err(), "unresolvable recipe dir is an error");
+
+    let _ = tokio::fs::remove_dir_all(&dir).await;
+}
+
+/// A built-in alias wins over a same-named file in recipe sources.
+#[test]
+fn builtin_aliases_resolve_before_recipe_files() {
+    assert!(crate::builtin_scripts::resolve("stop_dgx_services").is_some());
+    assert!(crate::builtin_scripts::resolve("scripts/prepare.sh").is_none());
+}

+ 10 - 4
src/bench_target.rs

@@ -12,10 +12,16 @@ use std::sync::{Arc, Mutex};
 
 pub(crate) fn served_model(recipe: &Recipe) -> Option<String> {
     let args = recipe.args.to_vec();
-    args.iter()
-        .position(|s| s == "--served-model-name")
-        .and_then(|i| args.get(i + 1))
-        .cloned()
+    // `--served-model-name` is the vLLM/SGLang spelling; TensorFold's HTTP app
+    // takes `--name` instead (its 2x 1017 lane serves `deepseek-v4.1-flash`).
+    ["--served-model-name", "--name"].iter().find_map(|flag| {
+        args.iter()
+            .position(|s| s == flag || s.starts_with(&format!("{flag}=")))
+            .and_then(|i| match args.get(i) {
+                Some(a) if a.contains('=') => a.split_once('=').map(|(_, v)| v.to_string()),
+                _ => args.get(i + 1).cloned(),
+            })
+    })
 }
 
 /// Bench target node: the explicit `--node` override, else rank 0 (falling

+ 14 - 7
src/builtin_scripts.rs

@@ -7,6 +7,20 @@
 //! the launcher resolves the alias to the script body and runs it over SSH on
 //! each node. No file copy from the operator's machine is needed.
 //!
+//! Only **generic** helpers belong here (cluster-wide service toggles, display
+//! memory unlocks). Anything that knows about a specific recipe — NAS paths,
+//! model revisions, per-rank staging — lives NEXT TO THAT RECIPE in the shared
+//! recipe repo and is referenced as a relative path:
+//!
+//! ```toml
+//! before_serve = [{ script = "scripts/prepare.sh" }]   # <recipe dir>/scripts/prepare.sh
+//! ```
+//!
+//! The launcher reads it from the recipe directory, uploads it, and runs it
+//! with `NODE_RANK` exported — same contract as a built-in alias. That keeps
+//! recipe-specific staging versioned with the recipe (and updatable by the
+//! add-on's recipe-repo sync) instead of requiring a launcher release.
+//!
 //! To add a new built-in:
 //! 1. Drop the `.sh` file into `host-scripts/`.
 //! 2. Add an `include_str!` line + a `(alias, body)` entry below.
@@ -39,13 +53,6 @@ pub static BUILTIN_SCRIPTS: &[BuiltinScript] = &[
         alias: "restore_display_ram",
         body: include_str!("../host-scripts/restore-display-ram.sh"),
     },
-    // Host-side staging for the TensorFold 2x 1017 lane: node-local EXL3
-    // weights, chat template, per-rank Engram packs, CUDA prebuild. Idempotent;
-    // needs NODE_RANK (exported by before_serve) to pick the right Engram pack.
-    BuiltinScript {
-        alias: "tensorfold_1017_prepare",
-        body: include_str!("../host-scripts/tensorfold-1017-prepare.sh"),
-    },
 ];
 
 /// Resolve an alias to its script body. Returns `None` (and the caller

+ 21 - 0
src/config.rs

@@ -378,6 +378,27 @@ pub(crate) fn recipe_file(recipe_name: &str, sources: &[String]) -> Option<PathB
 /// Load a recipe by name. Search order: `./recipes/<name>/recipe.toml` (cwd),
 /// then each `sources` entry (a dir containing `<name>/recipe.toml`, `~`
 /// expanded), then `<default_config_dir>/recipes/<name>/recipe.toml`.
+/// Locate the directory that holds `<name>/recipe.toml`, using the same
+/// search order as [`load_recipe`]. Recipe-relative artifacts (a `{ script =
+/// "scripts/prepare.sh" }` hook, `venv_setup_script`, patch files) live next
+/// to the recipe in the shared recipe repo — not in the launcher binary.
+pub async fn recipe_dir(recipe_name: &str, sources: &[String]) -> Result<std::path::PathBuf> {
+    let mut dirs: Vec<std::path::PathBuf> = vec![std::path::PathBuf::from("recipes")];
+    for src in sources {
+        dirs.push(local_path(src));
+    }
+    if let Some(dir) = default_config_dir() {
+        dirs.push(dir.join("recipes"));
+    }
+    for d in dirs {
+        let p = d.join(recipe_name).join("recipe.toml");
+        if tokio::fs::metadata(&p).await.is_ok() {
+            return Ok(d.join(recipe_name));
+        }
+    }
+    anyhow::bail!("recipe '{recipe_name}' not found in any recipe source")
+}
+
 pub async fn load_recipe(recipe_name: &str, sources: &[String]) -> Result<Recipe> {
     // Search order: `recipes/<name>` (launcher workspace), then `<name>`
     // (published bundle layout — a clone of the recipe registry is flat),

+ 1 - 0
src/docker/mod.rs

@@ -239,6 +239,7 @@ pub async fn serve(
         config,
         &cluster,
         &recipe,
+        recipe_name,
         opts.skip_before_serve,
         opts.dry_run,
     )

+ 1 - 0
src/native/mod.rs

@@ -184,6 +184,7 @@ pub async fn native_serve(
         config,
         &cluster,
         &recipe_obj,
+        recipe_name,
         opts.skip_before_serve,
         dry_run,
     )

+ 9 - 1
src/tmux/serve.rs

@@ -95,7 +95,15 @@ pub async fn tmux_serve(
         }
     }
 
-    crate::before_serve::run(config, &cluster, &recipe, skip_before_serve, dry_run).await?;
+    crate::before_serve::run(
+        config,
+        &cluster,
+        &recipe,
+        recipe_name,
+        skip_before_serve,
+        dry_run,
+    )
+    .await?;
 
     // Prepare the Python venv on each node if it doesn't exist yet.
     prepare_venv(config, &recipe, &cluster, recipe_name, dry_run).await?;