Browse Source

release: sm121 0.2.2 — dashboard bench window (params, node, live log, cancel, reopen)

Alexander Korolev 9 hours ago
parent
commit
d8dc94acad
13 changed files with 628 additions and 71 deletions
  1. 19 0
      CHANGELOG.md
  2. 1 1
      Cargo.lock
  3. 1 1
      Cargo.toml
  4. 44 3
      src/bench.rs
  5. 16 1
      src/bench_target.rs
  6. 1 0
      src/main.rs
  7. 5 4
      src/report/page.html
  8. 3 57
      src/web/api.rs
  9. 189 0
      src/web/bench_api.rs
  10. 50 1
      src/web/jobs.rs
  11. 4 1
      src/web/mod.rs
  12. 272 0
      src/web/ui.html
  13. 23 2
      src/web/ui.playwright.mjs

+ 19 - 0
CHANGELOG.md

@@ -3,6 +3,25 @@
 Semantic versioning for the launcher CLI (`sm121`). The add-on image has its
 own version and its own `sm121/CHANGELOG.md`; do not mix them.
 
+## 0.2.2 — 2026-10-28
+
+- **Dashboard bench window.** The recipes panel's `⚡ bench` button now opens a
+  bench window instead of firing the standard run blindly: choose recipe,
+  cluster, target node (`node`), run host (`ssh_host`, e.g. `pluto.lan`),
+  label, seed, concurrency, parallel, timeout, tokenizer, perf-only, depth and
+  no-think. The choices persist in `localStorage` (`sm121-bench-params`).
+- **Live harness log.** `GET /api/bench/log?recipe=…` tail-reads the newest
+  bench log for the recipe; the window polls it while the run starts so early
+  failures (readiness, tokenizer, harness) are visible before a long wait.
+- **Cancel.** `POST /api/bench/cancel` sets the job's cancellation flag;
+  `run_tool` polls it and kills the `tool-eval-bench` child (also the SSH child
+  for remote runs), so the job finishes failed and the log stops.
+- **Reopen.** The bench window is a named window: closing it leaves a dock chip,
+  the dock menu has `bench…`, and the last recipe/job is restored on reload.
+- Bench API now accepts `node`, `seed`, `concurrency`, `parallel`, `timeout`,
+  `tokenizer`, `no_think`; the report row's bench button posts to the parent
+  dashboard and opens the window.
+
 ## 0.2.1 — 2026-10-28
 
 - **FLARE cluster identification.** A cluster is only paired with an orbit if

+ 1 - 1
Cargo.lock

@@ -2941,7 +2941,7 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
 
 [[package]]
 name = "sm121"
-version = "0.2.1"
+version = "0.2.2"
 dependencies = [
  "anyhow",
  "axum",

+ 1 - 1
Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = "sm121"
-version = "0.2.1"
+version = "0.2.2"
 edition = "2024"
 license = "MIT"
 

+ 44 - 3
src/bench.rs

@@ -52,6 +52,9 @@ pub struct Args {
     /// Per-turn request timeout seconds forwarded to the harness (tool
     /// `--timeout`). Raise above the 120 s default for slow/thinking models.
     pub timeout: Option<u64>,
+    /// Job cancellation flag (web bench window). `run_tool` polls it and
+    /// kills the harness child; None for the CLI.
+    pub cancel: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
 }
 
 /// Served model name from the recipe's `--served-model-name` arg.
@@ -192,8 +195,22 @@ pub async fn run(config: &Config, a: &Args, dry_run: bool) -> Result<()> {
     crate::readiness::wait_ready(&base_url, crate::readiness::BENCH_WAIT_SECS).await?;
     eprintln!("bench: API ready — running {} (seed {})", bin, a.seed);
 
-    let code = run_tool(&bin, &args, &tool_scratch, &log_path, false, ssh_host)?;
+    let code = run_tool(
+        &bin,
+        &args,
+        &tool_scratch,
+        &log_path,
+        false,
+        ssh_host,
+        a.cancel.as_ref(),
+    )?;
     if code != 0 {
+        if a.cancel
+            .as_ref()
+            .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
+        {
+            anyhow::bail!("bench: cancelled");
+        }
         anyhow::bail!(
             "bench: tool exited with {code} (log: {})",
             log_path.display()
@@ -217,8 +234,22 @@ pub async fn run(config: &Config, a: &Args, dry_run: bool) -> Result<()> {
             }
         );
         fs::create_dir_all(&depth_scratch)?;
-        let dcode = run_tool(&bin, &depth_args, &depth_scratch, &log_path, true, ssh_host)?;
+        let dcode = run_tool(
+            &bin,
+            &depth_args,
+            &depth_scratch,
+            &log_path,
+            true,
+            ssh_host,
+            a.cancel.as_ref(),
+        )?;
         if dcode != 0 {
+            if a.cancel
+                .as_ref()
+                .is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
+            {
+                anyhow::bail!("bench: cancelled in depth suite");
+            }
             eprintln!(
                 "bench: WARNING depth suite exited with {dcode} — main report unaffected ({})",
                 log_path.display()
@@ -390,6 +421,7 @@ mod tests {
             &log,
             false,
             None,
+            None,
         )
         .unwrap();
         let body = fs::read_to_string(&log).unwrap();
@@ -409,7 +441,16 @@ mod tests {
         fs::create_dir_all(&tmp).unwrap();
         let log = tmp.join("append.log");
         let run = |a: &str, append| {
-            run_tool("sh", &["-c".into(), a.into()], &tmp, &log, append, None).unwrap()
+            run_tool(
+                "sh",
+                &["-c".into(), a.into()],
+                &tmp,
+                &log,
+                append,
+                None,
+                None,
+            )
+            .unwrap()
         };
         assert_eq!(run("echo main", false), 0);
         assert!(fs::read_to_string(&log).unwrap().contains("main\n"));

+ 16 - 1
src/bench_target.rs

@@ -8,7 +8,10 @@ use anyhow::Result;
 use std::fs;
 use std::path::Path;
 use std::process::Stdio;
+use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::{Arc, Mutex};
+use std::thread;
+use std::time::Duration;
 
 pub(crate) fn served_model(recipe: &Recipe) -> Option<String> {
     let args = recipe.args.to_vec();
@@ -92,6 +95,7 @@ pub(crate) fn run_tool(
     log_path: &Path,
     append_log: bool,
     ssh_host: Option<&str>,
+    cancel: Option<&Arc<AtomicBool>>,
 ) -> Result<i32> {
     let file = Arc::new(Mutex::new(if append_log {
         fs::OpenOptions::new()
@@ -150,7 +154,18 @@ pub(crate) fn run_tool(
     let f1 = file.clone();
     let t1 = std::thread::spawn(move || crate::bench_io::pump(stdout_pipe, true, f1));
     let t2 = std::thread::spawn(move || crate::bench_io::pump(stderr_pipe, false, file));
-    let status = child.wait()?;
+    // Bench cancellation: poll the job's flag; on request kill the harness
+    // child (the SSH child for a remote run, so the remote command drops too).
+    let status = loop {
+        if cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
+            let _ = child.kill();
+            break child.wait()?;
+        }
+        if let Some(s) = child.try_wait()? {
+            break s;
+        }
+        thread::sleep(Duration::from_millis(200));
+    };
     t1.join().ok();
     t2.join().ok();
     Ok(status.code().unwrap_or(-1))

+ 1 - 0
src/main.rs

@@ -282,6 +282,7 @@ async fn main() -> Result<()> {
                 ssh_host,
                 no_think,
                 timeout,
+                cancel: None,
             };
             crate::bench::run(&config, &args, dry_run).await?;
         }

+ 5 - 4
src/report/page.html

@@ -741,9 +741,9 @@ function setNote(recipe, text) {
 // The expanded row carries the controls before the toml link: serve/stop
 // (tracked dashboard jobs), a logs button (opens a logs WINDOW in the parent
 // dashboard via postMessage — same window as the cluster overview, not an
-// inline stream), and a bench launch (POST /api/bench — the standard campaign
-// as a job; the report lands in the shared recipe repo and re-appears in this
-// table via the live stream). The settings button opens the model-settings
+// inline stream), and a bench button (opens the parent dashboard's bench
+// window via postMessage: parameters, target node, live log, cancel). The
+// settings button opens the model-settings
 // dialog (per-agent client config, endpoint included on request).
 function actionBar(r) {
   if (!EMBED) return "";
@@ -752,7 +752,7 @@ function actionBar(r) {
     ? `<button class="act" data-act="stop" data-r="${esc(r.name)}" title="stop (dashboard job)">⏹ stop</button>`
     : `<button class="act" data-act="serve" data-r="${esc(r.name)}" title="serve (dashboard job, cluster auto)">▶ serve</button>`;
   if (running) b += `<button class="act" data-act="logs" data-r="${esc(r.name)}" title="open a logs window (head node)">▤ logs</button>`;
-  b += `<button class="act" data-act="bench" data-r="${esc(r.name)}" title="standard bench (seed 42, hardmode, perf c1,2) as a dashboard job — the report lands in the recipe repo">⚡ bench</button>`;
+  b += `<button class="act" data-act="bench" data-r="${esc(r.name)}" title="open the bench window: choose seed/concurrency/node/run host, watch the live harness log, cancel">⚡ bench</button>`;
   b += `<button class="act" data-act="settings" data-r="${esc(r.name)}" title="client settings for this recipe's model — pick your agent (pi, Claude Code, Codex, Cline, generic) and copy the block">⚙ settings</button>`;
   return `<div class="actbar">${b}<span class="actnote" data-r="${esc(r.name)}">${esc(NOTES[r.name] || "")}</span></div>`;
 }
@@ -763,6 +763,7 @@ document.addEventListener("click", async (e) => {
   e.stopPropagation(); // no row toggle under the action button
   const kind = b.dataset.act, recipe = b.dataset.r;
   if (kind === "logs") { parent.postMessage({ sm121: "openLogs", recipe }, "*"); return; }
+  if (kind === "bench") { parent.postMessage({ sm121: "openBench", recipe }, "*"); return; }
   if (kind === "settings") { msOpen(recipe); return; }
   const path = kind === "serve" ? "/api/serve" : kind === "stop" ? "/api/stop" : "/api/bench";
   b.disabled = true;

+ 3 - 57
src/web/api.rs

@@ -1,7 +1,9 @@
 //! REST endpoints. P1: `GET /api/state` (latest snapshot; probes once if
 //! the supervisor hasn't ticked yet).
 
-use axum::{extract::State, http::StatusCode, response::Json};
+use axum::extract::State;
+use axum::http::StatusCode;
+use axum::response::Json;
 use serde::Deserialize;
 
 use super::AppState;
@@ -118,62 +120,6 @@ pub(crate) async fn stop(
     Ok(Json(serde_json::json!({ "job": id })))
 }
 
-#[derive(Deserialize)]
-pub(crate) struct BenchReq {
-    pub cluster: String,
-    pub recipe: String,
-    /// Label for the run. A quality run without one is auto-labeled
-    /// `web-<UTC-stamp>` (dashboard campaigns stay distinguishable from
-    /// operator campaigns); perf-only runs default to `perf` in bench::run.
-    #[serde(default)]
-    pub label: Option<String>,
-    #[serde(default)]
-    pub perf_only: bool,
-    /// Run the context-depth suite after the main pass (off by default).
-    #[serde(default)]
-    pub depth: bool,
-    /// Run the harness on a remote host over SSH (its network stays off the
-    /// measurement path); None = the launcher host itself.
-    #[serde(default)]
-    pub ssh_host: Option<String>,
-}
-
-/// `POST /api/bench` — the standard bench (seed 42, hardmode, perf,
-/// concurrency 1,2) as a tracked job; the report + run package land in the
-/// shared recipe repo (`recipes/<lane>/reports/`).
-pub(crate) async fn bench(
-    app: State<AppState>,
-    Json(req): Json<BenchReq>,
-) -> Result<Json<serde_json::Value>, ApiError> {
-    let id = super::jobs::new_job(&app, "bench", &req.cluster, Some(&req.recipe))?;
-    let config = app.config.clone();
-    let app2 = (*app).clone();
-    let label = match req.label {
-        Some(l) => Some(l),
-        None if !req.perf_only => Some(format!("web-{}", crate::stamp::utc_stamp())),
-        None => None,
-    };
-    let args = crate::bench::Args {
-        recipe: req.recipe.clone(),
-        cluster: req.cluster.clone(),
-        node: None,
-        label,
-        concurrency: String::from("1,2"),
-        seed: 42,
-        parallel: None,
-        perf_only: req.perf_only,
-        depth: req.depth,
-        tokenizer: None,
-        ssh_host: req.ssh_host,
-        no_think: false,
-        timeout: None,
-    };
-    super::jobs::spawn(app2, id.clone(), async move {
-        crate::bench::run(&config, &args, false).await
-    });
-    Ok(Json(serde_json::json!({ "job": id })))
-}
-
 #[derive(Deserialize)]
 pub(crate) struct PowerReq {
     /// "off" | "on" | "switch"

+ 189 - 0
src/web/bench_api.rs

@@ -0,0 +1,189 @@
+//! Bench endpoints: parameterized batch run, live harness log, cancellation.
+//!
+//! `POST /api/bench` starts the same `bench::run` the CLI uses as a tracked job.
+//! The dashboard bench window chooses target node, run host, seed/concurrency,
+//! tokenizer and the optional perf/depth modes; `GET /api/bench/log` tail-reads
+//! the newest harness log for the recipe; `POST /api/bench/cancel` sets the
+//! job's cancellation flag, which kills the `tool-eval-bench` child.
+
+use axum::extract::{Query, State};
+use axum::http::StatusCode;
+use axum::response::Json;
+use serde::Deserialize;
+
+use super::AppState;
+
+type ApiError = (StatusCode, String);
+
+#[derive(Deserialize)]
+pub(crate) struct BenchReq {
+    pub cluster: String,
+    pub recipe: String,
+    /// Label for the run. A quality run without one is auto-labeled
+    /// `web-<UTC-stamp>` (dashboard campaigns stay distinguishable from
+    /// operator campaigns); perf-only runs default to `perf` in bench::run.
+    #[serde(default)]
+    pub label: Option<String>,
+    #[serde(default)]
+    pub perf_only: bool,
+    /// Run the context-depth suite after the main pass (off by default).
+    #[serde(default)]
+    pub depth: bool,
+    /// Run the harness on a remote host over SSH (its network stays off the
+    /// measurement path); None = the launcher host itself.
+    #[serde(default)]
+    pub ssh_host: Option<String>,
+    /// Bench target node inside the cluster (default: rank 0).
+    #[serde(default)]
+    pub node: Option<String>,
+    #[serde(default)]
+    pub seed: Option<u64>,
+    #[serde(default)]
+    pub concurrency: Option<String>,
+    #[serde(default)]
+    pub parallel: Option<u32>,
+    #[serde(default)]
+    pub no_think: bool,
+    #[serde(default)]
+    pub timeout: Option<u64>,
+    /// Local tokenizer path (uploaded to `ssh_host` when remote); needed when
+    /// the served alias is not a resolvable HF model id (e.g. FLARE lanes).
+    #[serde(default)]
+    pub tokenizer: Option<String>,
+}
+
+/// `POST /api/bench` — the standard bench (seed 42, hardmode, perf,
+/// concurrency 1,2) as a tracked job; the report + run package land in the
+/// shared recipe repo (`recipes/<lane>/reports/`).
+pub(crate) async fn bench(
+    app: State<AppState>,
+    Json(req): Json<BenchReq>,
+) -> Result<Json<serde_json::Value>, ApiError> {
+    let id = super::jobs::new_job(&app, "bench", &req.cluster, Some(&req.recipe))?;
+    let config = app.config.clone();
+    let app2 = (*app).clone();
+    let label = match req.label {
+        Some(l) => Some(l),
+        None if !req.perf_only => Some(format!("web-{}", crate::stamp::utc_stamp())),
+        None => None,
+    };
+    let cancel = super::jobs::cancel_flag(&app, &id);
+    let args = crate::bench::Args {
+        recipe: req.recipe.clone(),
+        cluster: req.cluster.clone(),
+        node: req.node,
+        label,
+        concurrency: req.concurrency.unwrap_or_else(|| String::from("1,2")),
+        seed: req.seed.unwrap_or(42),
+        parallel: req.parallel,
+        perf_only: req.perf_only,
+        depth: req.depth,
+        tokenizer: req.tokenizer,
+        ssh_host: req.ssh_host,
+        no_think: req.no_think,
+        timeout: req.timeout,
+        cancel,
+    };
+    super::jobs::spawn(app2, id.clone(), async move {
+        crate::bench::run(&config, &args, false).await
+    });
+    Ok(Json(serde_json::json!({ "job": id })))
+}
+
+#[derive(Deserialize)]
+pub(crate) struct BenchCancelReq {
+    pub job: String,
+}
+
+/// `POST /api/bench/cancel` — set the bench job's cancellation flag; the
+/// harness child is killed by `run_tool`, and the job finishes failed.
+pub(crate) async fn bench_cancel(
+    app: State<AppState>,
+    Json(req): Json<BenchCancelReq>,
+) -> Result<Json<serde_json::Value>, ApiError> {
+    if !super::jobs::cancel_bench(&app, &req.job) {
+        return Err((
+            StatusCode::NOT_FOUND,
+            format!("no running bench job '{}'", req.job),
+        ));
+    }
+    Ok(Json(
+        serde_json::json!({ "job": req.job, "cancelling": true }),
+    ))
+}
+
+#[derive(Deserialize)]
+pub(crate) struct BenchLogReq {
+    pub recipe: String,
+    #[serde(default)]
+    pub lines: Option<usize>,
+}
+
+/// `GET /api/bench/log?recipe=<name>` — the newest bench log for the recipe;
+/// the UI polls this in the bench window to show the harness early.
+pub(crate) async fn bench_log(
+    app: State<AppState>,
+    Query(req): Query<BenchLogReq>,
+) -> Result<Json<serde_json::Value>, ApiError> {
+    let file =
+        crate::config::recipe_file(&req.recipe, &app.config.recipe_sources).ok_or_else(|| {
+            (
+                StatusCode::NOT_FOUND,
+                format!("recipe '{}' not found", req.recipe),
+            )
+        })?;
+    let dir = file.parent().map(|p| p.join("reports"));
+    let Some(dir) = dir else {
+        return Err((
+            StatusCode::NOT_FOUND,
+            "recipe has no reports dir".to_string(),
+        ));
+    };
+    let mut entries = tokio::fs::read_dir(&dir)
+        .await
+        .map_err(|e| (StatusCode::NOT_FOUND, format!("no reports dir: {e}")))?;
+    let mut latest: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
+    while let Some(entry) = entries
+        .next_entry()
+        .await
+        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
+    {
+        let path = entry.path();
+        if path.extension() != Some(std::ffi::OsStr::new("log")) {
+            continue;
+        }
+        let md = entry
+            .metadata()
+            .await
+            .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+        let m = md.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
+        if latest.as_ref().is_none_or(|(old, _)| m > *old) {
+            latest = Some((m, path));
+        }
+    }
+    let Some((_, path)) = latest else {
+        return Err((StatusCode::NOT_FOUND, "no bench log yet".to_string()));
+    };
+    let text = tokio::fs::read_to_string(&path)
+        .await
+        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
+    let n = req.lines.unwrap_or(160).clamp(20, 1000);
+    let all: Vec<&str> = text.lines().collect();
+    let lines = all[all.len().saturating_sub(n)..].to_vec();
+    let (job, running) = {
+        let jobs = app.jobs.lock().unwrap();
+        let j = jobs
+            .iter()
+            .find(|j| j.kind == "bench" && j.recipe.as_deref() == Some(req.recipe.as_str()));
+        (
+            j.map(|j| j.id.clone()),
+            j.is_some_and(|j| j.status == super::jobs::JobStatus::Running),
+        )
+    };
+    Ok(Json(serde_json::json!({
+        "path": path.display().to_string(),
+        "lines": lines,
+        "job": job,
+        "running": running,
+    })))
+}

+ 50 - 1
src/web/jobs.rs

@@ -6,7 +6,8 @@
 use super::AppState;
 use super::events::Event;
 use serde::{Deserialize, Serialize};
-use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::Arc;
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
 use std::time::{SystemTime, UNIX_EPOCH};
 
 #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
@@ -27,6 +28,10 @@ pub struct Job {
     pub status: JobStatus,
     pub message: String,
     pub started: u64,
+    /// Bench cancellation flag: `run_tool` polls it and kills the harness
+    /// child. Not persisted (a reload starts a new flag).
+    #[serde(skip)]
+    pub cancel: Option<Arc<AtomicBool>>,
 }
 
 static COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -94,6 +99,7 @@ pub fn new_job(
             status: JobStatus::Running,
             message: "started".into(),
             started: now_ts(),
+            cancel: Some(Arc::new(AtomicBool::new(false))),
         });
         trim_finished(&mut jobs);
         save_locked(&jobs);
@@ -134,6 +140,49 @@ pub fn finish_job(app: &AppState, id: &str, ok: bool, message: String) {
     });
 }
 
+/// The bench job's cancellation flag (None for other kinds).
+pub fn cancel_flag(app: &AppState, id: &str) -> Option<Arc<AtomicBool>> {
+    let jobs = app.jobs.lock().unwrap();
+    jobs.iter()
+        .find(|j| j.id == id && j.kind == "bench")
+        .and_then(|j| j.cancel.clone())
+}
+
+/// Request cancellation of a running bench job. The job stays `Running`
+/// until the harness process exits; the flag makes `run_tool` kill it.
+pub fn cancel_bench(app: &AppState, id: &str) -> bool {
+    let target = {
+        let jobs = app.jobs.lock().unwrap();
+        jobs.iter()
+            .find(|j| j.id == id && j.kind == "bench" && j.status == JobStatus::Running)
+            .map(|j| {
+                (
+                    j.kind.clone(),
+                    j.cluster.clone(),
+                    j.recipe.clone(),
+                    j.cancel.clone(),
+                )
+            })
+    };
+    let Some((kind, cluster, recipe, flag)) = target else {
+        return false;
+    };
+    if let Some(f) = flag {
+        f.store(true, Ordering::Relaxed);
+        emit(
+            app,
+            id,
+            &kind,
+            "cancelling",
+            &cluster,
+            recipe.as_deref(),
+            "cancelling",
+        );
+        return true;
+    }
+    false
+}
+
 fn trim_finished(jobs: &mut Vec<Job>) {
     if jobs.len() <= MAX_JOBS {
         return;

+ 4 - 1
src/web/mod.rs

@@ -9,6 +9,7 @@
 //! Design: `specs/tech-architecture/web-dashboard.md`.
 
 mod api;
+mod bench_api;
 mod cache_api;
 pub mod events;
 pub(crate) mod flare_nodes;
@@ -268,7 +269,9 @@ pub fn router(app: AppState) -> Router {
         .route("/api/state", get(api::state))
         .route("/api/serve", post(api::serve))
         .route("/api/stop", post(api::stop))
-        .route("/api/bench", post(api::bench))
+        .route("/api/bench", post(bench_api::bench))
+        .route("/api/bench/cancel", post(bench_api::bench_cancel))
+        .route("/api/bench/log", get(bench_api::bench_log))
         .route("/api/model-settings", get(model_settings::handle))
         .route("/api/power", post(api::power))
         .route("/api/recipes", get(api::recipes))

+ 272 - 0
src/web/ui.html

@@ -272,6 +272,30 @@
   }
   .setstat { font-size: 12px; color: var(--muted); }
 
+  /* --- bench window: parameters + live harness log ------------------------- */
+  .benchwin .win-body { display: flex; flex-direction: column; padding: 0; }
+  .benchbar {
+    display: flex; gap: 8px; align-items: center; padding: 8px 10px;
+    border-bottom: 1px solid var(--border); flex-wrap: wrap; font-size: 12px;
+  }
+  .benchbar .adv { color: var(--muted); flex: 1; min-width: 100px; }
+  .benchbar .abtn { height: 26px; }
+  .benchform {
+    display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 6px 10px;
+    padding: 8px 10px; border-bottom: 1px solid var(--border); flex-shrink: 0;
+    font-size: 12px;
+  }
+  .benchform label { display: flex; align-items: center; gap: 6px; color: var(--muted); }
+  .benchform input[type=text], .benchform input[type=number], .benchform select {
+    width: 100%; height: 25px; box-sizing: border-box; background: var(--btn);
+    color: var(--fg); border: 1px solid var(--border); border-radius: 5px; padding: 2px 6px;
+    font: inherit;
+  }
+  .benchform .ckrow { display: flex; gap: 14px; flex-wrap: wrap; }
+  .benchform .ckrow label { color: var(--fg); }
+  .benchstatus { padding: 6px 10px; font-size: 12px; color: var(--muted); border-bottom: 1px solid var(--border); }
+  .benchlog { margin: 0; flex: 1; min-height: 80px; }
+
   /* --- polish: skeleton + attention ------------------------------------------ */
   .skeleton { height: 14px; border-radius: 6px; margin: 10px 0;
     background: linear-gradient(90deg, var(--btn) 25%, var(--btn-hover) 50%, var(--btn) 75%);
@@ -856,6 +880,7 @@ function toggleDockMenu() {
   item("logs…", openLogsPicker);
   item("raw", openRaw);
   item("cache…", openCache);
+  item("bench…", () => openBench(readBenchParams().recipe || ""));
   item("settings…", openSettings);
   document.body.appendChild(m);
   // Anchor the menu to the + button that opened it (the chips flow left,
@@ -915,6 +940,7 @@ function openLogsForRecipe(recipe) {
 window.addEventListener("message", (e) => {
   const d = e.data;
   if (d && d.sm121 === "openLogs" && typeof d.recipe === "string") openLogsForRecipe(d.recipe);
+  if (d && d.sm121 === "openBench" && typeof d.recipe === "string") openBench(d.recipe);
 });
 
 // --- SSE -------------------------------------------------------------------
@@ -2139,6 +2165,251 @@ async function openSettings(g) {
   return handle;
 }
 
+// --- bench window: parameters, target node, live harness log, cancel --------
+const BENCH_PARAMS_KEY = "sm121-bench-params";
+const BENCH_JOB_KEY = "sm121-bench-job";
+let benchWin = null;
+let benchTimer = null;
+let benchJobId = null;
+function readBenchParams() {
+  try { return JSON.parse(localStorage.getItem(BENCH_PARAMS_KEY) || "{}"); } catch { return {}; }
+}
+function saveBenchParams(p) {
+  try { localStorage.setItem(BENCH_PARAMS_KEY, JSON.stringify(p)); } catch {}
+}
+function saveBenchJob(job, recipe) {
+  benchJobId = job || null;
+  try { localStorage.setItem(BENCH_JOB_KEY, JSON.stringify({ job: benchJobId, recipe })); } catch {}
+}
+function readBenchJob() {
+  try { return JSON.parse(localStorage.getItem(BENCH_JOB_KEY) || "{}"); } catch { return {}; }
+}
+function stopBenchPoll() { if (benchTimer) { clearInterval(benchTimer); benchTimer = null; } }
+async function openBench(recipeArg) {
+  if (benchWin) {
+    activateWindow(benchWin);
+    if (recipeArg) {
+      const sel = benchWin.body.querySelector("#bRecipe");
+      if (sel) sel.value = recipeArg;
+    }
+    if (benchJobId) startBenchPoll();
+    return benchWin;
+  }
+  const saved = readBenchParams();
+  const jobSaved = readBenchJob();
+  const snap = cur();
+  const clusters = snap ? snap.clusters : [];
+  // Recipe: arg from the recipe row, else the last persisted one.
+  const recipe = recipeArg || saved.recipe || "";
+  // Cluster: prefer where the recipe is live, then the saved choice, then first.
+  let cluster = saved.cluster || (clusters[0] ? clusters[0].name : "");
+  let node = saved.node || "";
+  if (recipe && snap) {
+    for (const c of snap.clusters) {
+      for (const n of c.nodes) {
+        if ((n.containers || []).some((x) => x.recipe === recipe)) {
+          cluster = c.name; node = n.name;
+          break;
+        }
+      }
+    }
+  }
+  const handle = makeWin({
+    title: "bench", short: "bench",
+    x: 70, y: 60, w: Math.min(820, innerWidth - 50), h: Math.min(620, innerHeight * 0.8),
+    bodyHtml: `<div class="benchbar">
+        <span class="adv benchcmd">bench</span>
+        <span class="benchstatus"></span>
+        <button class="abtn bstart">▶ run</button>
+        <button class="abtn bcancel" disabled>⏹ cancel</button>
+      </div>
+      <div class="benchform">
+        <label>recipe</label><select id="bRecipe"></select>
+        <label>cluster</label><select id="bCluster"></select>
+        <label>node</label><select id="bNode"></select>
+        <label>run host</label><input id="bHost" type="text" placeholder="pluto.lan (ssh_host, optional)">
+        <label>label</label><input id="bLabel" type="text" placeholder="blank = web-<UTC stamp>">
+        <label>seed</label><input id="bSeed" type="number" min="0" value="42">
+        <label>concurrency</label><input id="bConc" type="text" value="1,2">
+        <label>parallel</label><input id="bPar" type="number" min="1" placeholder="serial">
+        <label>timeout</label><input id="bTimeout" type="number" min="1" placeholder="120">
+        <label>tokenizer</label><input id="bTokenizer" type="text" placeholder="tokenizer.json (optional)">
+        <div class="ckrow">
+          <label><input id="bPerf" type="checkbox"> perf only</label>
+          <label><input id="bDepth" type="checkbox"> depth suite</label>
+          <label><input id="bNoThink" type="checkbox"> no think</label>
+        </div>
+      </div>
+      <pre class="logpre benchlog"></pre>`,
+    extraCls: "benchwin", removeOnClose: false,
+    onClosed: () => { stopBenchPoll(); },
+  });
+  wins.bench = handle;
+  benchWin = handle;
+  const body = handle.body;
+  const recipeSel = body.querySelector("#bRecipe");
+  const clusterSel = body.querySelector("#bCluster");
+  const nodeSel = body.querySelector("#bNode");
+  const status = body.querySelector(".benchstatus");
+  const pre = body.querySelector(".benchlog");
+  const startBtn = body.querySelector(".bstart");
+  const cancelBtn = body.querySelector(".bcancel");
+  // Recipe list
+  const recipes = state.recipes || [];
+  recipeSel.innerHTML = "";
+  const seen = new Set();
+  for (const r of recipes) {
+    if (seen.has(r.name)) continue;
+    seen.add(r.name);
+    const o = document.createElement("option");
+    o.value = r.name;
+    o.textContent = r.name;
+    recipeSel.appendChild(o);
+  }
+  if (recipe && !seen.has(recipe)) {
+    const o = document.createElement("option");
+    o.value = recipe; o.textContent = recipe;
+    recipeSel.appendChild(o);
+  }
+  recipeSel.value = recipe;
+  function fillClusters() {
+    clusterSel.innerHTML = "";
+    for (const c of clusters) {
+      const o = document.createElement("option");
+      o.value = c.name; o.textContent = c.name + (c.up ? "" : " (offline)");
+      clusterSel.appendChild(o);
+    }
+    if (!clusters.length) {
+      const o = document.createElement("option");
+      o.value = ""; o.textContent = "no clusters";
+      clusterSel.appendChild(o);
+    }
+    clusterSel.value = cluster || (clusters[0] ? clusters[0].name : "");
+  }
+  function fillNodes() {
+    const cname = clusterSel.value;
+    const c = clusters.find((x) => x.name === cname);
+    nodeSel.innerHTML = "<option value=''>auto (rank 0)</option>";
+    if (c) for (const n of c.nodes) {
+      const o = document.createElement("option");
+      o.value = n.name; o.textContent = n.name + (n.containers.length ? " ★" : "");
+      nodeSel.appendChild(o);
+    }
+    nodeSel.value = node || "";
+  }
+  fillClusters(); fillNodes();
+  // Restore the persisted fields (recipe/cluster/node already; the rest)
+  body.querySelector("#bHost").value = saved.host || "";
+  body.querySelector("#bLabel").value = saved.label || "";
+  body.querySelector("#bSeed").value = saved.seed ?? 42;
+  body.querySelector("#bConc").value = saved.concurrency || "1,2";
+  body.querySelector("#bPar").value = saved.parallel || "";
+  body.querySelector("#bTimeout").value = saved.timeout || "";
+  body.querySelector("#bTokenizer").value = saved.tokenizer || "";
+  body.querySelector("#bPerf").checked = !!saved.perf_only;
+  body.querySelector("#bDepth").checked = !!saved.depth;
+  body.querySelector("#bNoThink").checked = !!saved.no_think;
+  clusterSel.onchange = () => { node = ""; fillNodes(); persist(); };
+  recipeSel.onchange = () => persist();
+  nodeSel.onchange = () => persist();
+  function persist() {
+    saveBenchParams({
+      recipe: recipeSel.value,
+      cluster: clusterSel.value,
+      node: nodeSel.value,
+      host: body.querySelector("#bHost").value.trim(),
+      label: body.querySelector("#bLabel").value.trim(),
+      seed: Number(body.querySelector("#bSeed").value) || 42,
+      concurrency: body.querySelector("#bConc").value.trim() || "1,2",
+      parallel: body.querySelector("#bPar").value ? Number(body.querySelector("#bPar").value) : null,
+      timeout: body.querySelector("#bTimeout").value ? Number(body.querySelector("#bTimeout").value) : null,
+      tokenizer: body.querySelector("#bTokenizer").value.trim() || null,
+      perf_only: body.querySelector("#bPerf").checked,
+      depth: body.querySelector("#bDepth").checked,
+      no_think: body.querySelector("#bNoThink").checked,
+    });
+  }
+  body.querySelectorAll("input, select").forEach((el) => el.addEventListener("change", persist));
+  async function startBench() {
+    const recipe = recipeSel.value;
+    const cluster = clusterSel.value;
+    if (!recipe || !cluster) { status.textContent = "pick recipe and cluster"; return; }
+    const payload = {
+      cluster, recipe,
+      node: nodeSel.value || null,
+      ssh_host: body.querySelector("#bHost").value.trim() || null,
+      seed: Number(body.querySelector("#bSeed").value) || 42,
+      concurrency: body.querySelector("#bConc").value.trim() || "1,2",
+      parallel: body.querySelector("#bPar").value ? Number(body.querySelector("#bPar").value) : null,
+      timeout: body.querySelector("#bTimeout").value ? Number(body.querySelector("#bTimeout").value) : null,
+      tokenizer: body.querySelector("#bTokenizer").value.trim() || null,
+      perf_only: body.querySelector("#bPerf").checked,
+      depth: body.querySelector("#bDepth").checked,
+      no_think: body.querySelector("#bNoThink").checked,
+    };
+    const label = body.querySelector("#bLabel").value.trim();
+    if (label) payload.label = label;
+    persist();
+    startBtn.disabled = true;
+    status.textContent = "starting…";
+    try {
+      const j = await api("/api/bench", payload);
+      saveBenchJob(j.job, recipe);
+      status.textContent = "job " + j.job + " running — live log below";
+      cancelBtn.disabled = false;
+      startBenchPoll();
+    } catch (e) {
+      status.textContent = "error: " + e.message;
+      startBtn.disabled = false;
+    }
+  }
+  cancelBtn.onclick = async () => {
+    if (!benchJobId) return;
+    cancelBtn.disabled = true;
+    status.textContent = "cancelling…";
+    try {
+      const res = await fetch("/api/bench/cancel", {
+        method: "POST", headers: { ...AUTH(), "content-type": "application/json" },
+        body: JSON.stringify({ job: benchJobId }),
+      });
+      const j = await res.json().catch(() => ({}));
+      if (!res.ok) status.textContent = "cancel error: " + (j.error || res.status);
+    } catch (e) { status.textContent = "cancel error: " + e.message; }
+  };
+  async function pollBenchLog() {
+    if (!recipeSel.value) return;
+    try {
+      const res = await fetch("/api/bench/log?recipe=" + encodeURIComponent(recipeSel.value) + "&lines=220", { headers: AUTH() });
+      if (res.status === 404) { status.textContent = "waiting for the first log line…"; return; }
+      const j = await res.json();
+      if (!res.ok) { status.textContent = "log error: " + (j.error || res.status); return; }
+      if (j.job && !benchJobId) { benchJobId = j.job; saveBenchJob(j.job, recipeSel.value); }
+      pre.textContent = (j.lines || []).join("\n");
+      const atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 24;
+      if (atBottom) pre.scrollTop = pre.scrollHeight;
+      status.textContent = j.running ? "running — " + j.path : "finished — " + j.path;
+      if (j.running) {
+        cancelBtn.disabled = false;
+      } else {
+        cancelBtn.disabled = true;
+        startBtn.disabled = false;
+        stopBenchPoll();
+      }
+    } catch (e) { status.textContent = "log error: " + e.message; }
+  }
+  function startBenchPoll() {
+    if (benchTimer) return;
+    benchTimer = setInterval(pollBenchLog, 2000);
+    pollBenchLog();
+  }
+  startBtn.onclick = startBench;
+  // Reopening a closed window with a running job restores the live view.
+  const savedJob = jobSaved.job;
+  if (savedJob) { benchJobId = savedJob; cancelBtn.disabled = true; startBenchPoll(); }
+  saveWinState();
+  return handle;
+}
+
 async function openRaw(g) {
   if (rawWin) { activateWindow(rawWin); return rawWin; }
   const r = (k, d) => (g && Number.isFinite(parseFloat(g[k])) ? parseFloat(g[k]) : d);
@@ -2464,6 +2735,7 @@ async function boot() {
   // optional windows (their own SSE/API calls are cheap and idempotent)
   restoreWindow("recipes", openRecipes);
   restoreWindow("raw", openRaw);
+  restoreWindow("bench", () => openBench(readBenchParams().recipe || ""));
   setInterval(async () => {
     try {
       const res = await fetch("/api/state", { headers: AUTH() });

+ 23 - 2
src/web/ui.playwright.mjs

@@ -282,8 +282,8 @@ ok(
 // --- 13. raw window (via the dock + new-window menu) -------------------------------
 await page.click("#dock .plus");
 ok(
-  "dock + menu lists topo / state / jobs / recipes / logs / raw / cache / settings",
-  (await page.locator(".dockmenu .dock-chip").count()) === 8,
+  "dock + menu lists topo / state / jobs / recipes / logs / raw / cache / bench / settings",
+  (await page.locator(".dockmenu .dock-chip").count()) === 9,
 );
 await page.click('.dockmenu .dock-chip:has-text("raw")');
 await page.waitForSelector(".rawwin", { timeout: 10000 });
@@ -788,6 +788,27 @@ ok(
 await rzWin.locator(".win-bar .wbtn[data-act=close]").click();
 
 // --- no page JS errors across the whole flow -------------------------------------
+// --- bench window: dialog, remembered params, close/reopen -----------------
+await page.click("#dockplus");
+await page.click('.dockmenu .dock-chip:has-text("bench")');
+await page.waitForSelector(".benchwin", { timeout: 10000 });
+ok("bench window opens with parameter form + log pane",
+  (await page.locator(".benchwin .benchform").count()) === 1 &&
+  (await page.locator(".benchwin .benchlog").count()) === 1 &&
+  (await page.locator(".benchwin #bCluster").count()) === 1 &&
+  (await page.locator(".benchwin #bNode").count()) === 1);
+// params persist in localStorage (even without a live recipe)
+await page.fill(".benchwin #bSeed", "7");
+await page.fill(".benchwin #bConc", "1,2,4");
+await page.evaluate(() => localStorage.getItem("sm121-bench-params"));
+ok("bench params are stored in localStorage",
+  (await page.evaluate(() => localStorage.getItem("sm121-bench-params") || "")).includes("\"seed\":7"));
+await page.click(".benchwin .wbtn[data-act=close]");
+ok("closed bench leaves a dock chip",
+  (await page.locator("#dock .dock-chip:has-text('bench')").count()) === 1);
+await page.click("#dock .dock-chip:has-text('bench')");
+ok("bench window reopens after close", await page.locator(".benchwin").isVisible());
+
 ok("no page JS errors", pageErrors.length === 0, pageErrors.join("; "));
 
 await browser.close();