Igneum Miner: the NVIDIA power cap is judged by reading nvidia-smi back (anything but the requested watts = NOT applied), the card tile and Settings say 'power cap: N W applied' or 'power cap NOT applied (needs the administrator prompt)' with a Retry, the elevated step runs through the window host (ShellExecuteEx runas, a UI context) with the PowerShell path as the 150 s fallback, events either way, cap state in the stability line (PC 2 mined uncapped at 118 MH/s on 0.3.1)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-04 13:43:04 +00:00
parent fd0f1939bf
commit 377693d9fa
10 changed files with 2146 additions and 28 deletions

View file

@ -108,6 +108,11 @@ pub struct Packaged {
pub live_page: String,
#[serde(default)]
pub download_page: String,
/// Consensus parameters the packager pins for the bundled node: the engine writes them to
/// <app data>/override-params.json and starts igneumd with --override-params-file (the difficulty v2 activation
/// height, 4 October 2026: `{"difficulty_v2_activation_daa": N}`). Absent or empty = no override file.
#[serde(default)]
pub node_override_params: Option<serde_json::Value>,
}
impl Packaged {
@ -183,3 +188,16 @@ impl Runtime {
format!("grpc://127.0.0.1:{}", self.rpc_port)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn packaged_carries_the_node_override_params() {
let p: Packaged = serde_json::from_str(r#"{"update_manifest":"","node_override_params":{"difficulty_v2_activation_daa":123456}}"#).unwrap();
assert_eq!(p.node_override_params.as_ref().unwrap()["difficulty_v2_activation_daa"], 123456);
let p: Packaged = serde_json::from_str(r#"{"update_manifest":""}"#).unwrap();
assert!(p.node_override_params.is_none());
}
}

View file

@ -100,6 +100,11 @@ pub fn apply_defaults(c: &mut CardState) {
}
/// NVIDIA power limits per card index: (default, current, min, max) in watts.
#[cfg(target_os = "macos")]
pub fn nvidia_power_limits() -> std::collections::HashMap<String, (f64, f64, f64, f64)> {
std::collections::HashMap::new()
}
#[cfg(not(target_os = "macos"))]
pub fn nvidia_power_limits() -> std::collections::HashMap<String, (f64, f64, f64, f64)> {
let mut out = std::collections::HashMap::new();

View file

@ -48,8 +48,12 @@ pub enum Cmd {
ClockCheck,
ClockSync,
ClockSynced(Result<String, String>),
/// the elevated nvidia-smi -pl step finished: (what was asked, result)
PowerApplied(String, Result<(), String>),
/// the elevated nvidia-smi -pl step finished: (what was asked, how it ran, the limits read back per device)
PowerApplied(String, Result<(), String>, std::collections::HashMap<String, f64>),
/// the window host ran the elevated command line (Windows): Ok or the reason
ElevatedDone(Result<(), String>),
/// the user asked for the cap again (the Retry button)
ApplyPower,
Quit,
}
@ -357,6 +361,8 @@ pub struct Engine {
telemetry_retry_at: Instant,
power_busy: bool,
power_restore_pending: bool,
/// an elevated step handed to the window host: (command line, what, requested watts per device, since)
power_via_host: Option<(String, String, std::collections::HashMap<String, f64>, Instant)>,
stability: std::collections::HashMap<usize, Stability>,
last_stability: Instant,
last_settings_save: Instant,
@ -422,6 +428,7 @@ impl Engine {
telemetry_retry_at: now,
power_busy: false,
power_restore_pending: false,
power_via_host: None,
stability: std::collections::HashMap::new(),
last_stability: now,
last_settings_save: now,
@ -647,26 +654,56 @@ impl Engine {
self.resolve_clock();
self.clock_next_https = Instant::now() + Duration::from_secs(6);
}
Cmd::PowerApplied(what, r) => {
Cmd::PowerApplied(what, r, readback) => {
self.power_busy = false;
match r {
Ok(()) => {
self.shared.event("ok", &format!("GPU power cap set: {what}"));
let mut st = self.st();
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled) {
c.power_applied = true;
c.power_note = String::new();
self.power_via_host = None;
// the truth is what nvidia-smi reads back, not whether the prompt said yes
let mut applied = Vec::new();
let mut missing = Vec::new();
{
let mut st = self.st();
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0) {
let want = requested_watts(c);
let got = readback.get(&c.device).copied().unwrap_or(c.power_limit_w);
if got > 0.0 {
c.power_limit_w = got;
}
}
Err(e) => {
self.shared.event("error", &format!("GPU power cap not applied ({e}); mining at the card's current limit"));
let mut st = self.st();
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled) {
if got > 0.0 && (got - want).abs() < 1.0 {
c.power_applied = true;
c.power_note = format!("power cap: {} W applied", want as u64);
applied.push(format!("{} {} W", c.name, want as u64));
} else {
c.power_applied = false;
c.power_note = format!("power cap not applied: {e}");
c.power_note = format!("power cap NOT applied (needs the administrator prompt): card reports {} W, wanted {} W", got as u64, want as u64);
missing.push(format!("{} reports {} W, wanted {} W", c.name, got as u64, want as u64));
}
}
}
if !applied.is_empty() {
self.shared.event("ok", &format!("GPU power cap in force: {}", applied.join(", ")));
}
if !missing.is_empty() {
let why = match &r {
Ok(()) => "the step ran but the card did not take it".to_string(),
Err(e) => e.clone(),
};
self.shared.event("error", &format!("GPU power cap NOT applied ({why}): {}. Retry from the card tile.", missing.join("; ")));
}
if applied.is_empty() && missing.is_empty() {
self.shared.log(&format!("power cap: nothing to read back for {what}"));
}
}
Cmd::ElevatedDone(r) => {
if let Some((line, what, want, _)) = self.power_via_host.take() {
self.shared.log(&format!("window host ran the elevated step ({}): {line}", match &r { Ok(()) => "ok".to_string(), Err(e) => e.clone() }));
self.finish_power(what, r, want);
}
}
Cmd::ApplyPower => {
for c in self.st().mining.cards.iter_mut().filter(|c| c.vendor == "nvidia") {
c.power_applied = false; // force the step again
}
self.apply_power_limits("retry");
}
Cmd::Quit => {
self.quitting = true;
@ -727,6 +764,9 @@ impl Engine {
for p in &r.peers {
a.push(format!("--addpeer={p}"));
}
if let Some(path) = self.node_override_file() {
a.push(format!("--override-params-file={}", path.display()));
}
a.extend(["--nodnsseed", "--disable-upnp", "--nologfiles", "--yes"].iter().map(|s| s.to_string()));
if r.unsynced_mining {
a.push("--enable-unsynced-mining".into());
@ -734,6 +774,29 @@ impl Engine {
a
}
/// The packager's consensus parameters (igneum-app.json `node_override_params`, for example the difficulty v2
/// activation height) written to <app data>/override-params.json for --override-params-file. None when the
/// package pins nothing, so the node runs on the network's defaults as before.
fn node_override_file(&self) -> Option<std::path::PathBuf> {
let v = self.shared.packaged.node_override_params.as_ref()?;
if v.as_object().map(|o| o.is_empty()).unwrap_or(true) {
return None;
}
let path = self.shared.runtime.app_dir.join("override-params.json");
let text = serde_json::to_string_pretty(v).ok()?;
if std::fs::read_to_string(&path).ok().as_deref() != Some(text.as_str()) {
if let Some(d) = path.parent() {
let _ = std::fs::create_dir_all(d);
}
if let Err(e) = std::fs::write(&path, &text) {
self.shared.log(&format!("could not write {}: {e}; the node starts without the override file", path.display()));
return None;
}
self.shared.log(&format!("node override params written to {}: {}", path.display(), text.replace('\n', " ")));
}
Some(path)
}
fn start_node(&mut self) {
self.node_starts += 1;
let seg = if self.node_starts > 1 { format!("-r{}", self.node_starts) } else { String::new() };
@ -1046,14 +1109,7 @@ impl Engine {
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0) {
let pct = if c.power_pct == 0 { 80 } else { c.power_pct.clamp(60, 100) };
c.power_pct = pct;
let mut watts = c.power_default_w * pct as f64 / 100.0;
if c.power_min_w > 0.0 {
watts = watts.max(c.power_min_w);
}
if c.power_max_w > 0.0 {
watts = watts.min(c.power_max_w);
}
let watts = watts.round();
let watts = requested_watts(c);
if (c.power_limit_w - watts).abs() < 1.0 && c.power_applied {
continue;
}
@ -1070,10 +1126,30 @@ impl Engine {
self.shared.log(&format!("power cap ({why}): {}", cmds.join(" & ")));
let line = cmds.join(" & ");
let what = what.join(", ");
let want: std::collections::HashMap<String, f64> = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0).map(|c| (c.device.clone(), requested_watts(c))).collect();
if self.wrapper && cfg!(windows) {
// the window host has a UI context: it shows the administrator prompt and reports back on stdin
self.power_via_host = Some((line.clone(), what.clone(), want, Instant::now()));
println!("ELEVATE {line}");
let _ = std::io::stdout().flush();
return;
}
let shared = self.shared.clone();
std::thread::spawn(move || {
let r = crate::platform::run_elevated(&line);
shared.send(Cmd::PowerApplied(what, r));
std::thread::sleep(Duration::from_millis(800));
let back: std::collections::HashMap<String, f64> = crate::detect::nvidia_power_limits().into_iter().map(|(k, v)| (k, v.1)).collect();
shared.send(Cmd::PowerApplied(what, r, back));
});
}
/// After an elevated step (ours or the host's): read the limits back and judge.
fn finish_power(&mut self, what: String, r: Result<(), String>, _want: std::collections::HashMap<String, f64>) {
let shared = self.shared.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(800));
let back: std::collections::HashMap<String, f64> = crate::detect::nvidia_power_limits().into_iter().map(|(k, v)| (k, v.1)).collect();
shared.send(Cmd::PowerApplied(what, r, back));
});
}
@ -1125,6 +1201,20 @@ impl Engine {
self.last_stability = now;
self.stability_line();
}
if let Some((line, what, want, since)) = self.power_via_host.clone() {
if now.duration_since(since) > Duration::from_secs(150) {
self.power_via_host = None;
self.shared.log("the window host did not answer the elevated step in 150 s; running it through PowerShell");
let shared = self.shared.clone();
std::thread::spawn(move || {
let r = crate::platform::run_elevated(&line);
std::thread::sleep(Duration::from_millis(800));
let back: std::collections::HashMap<String, f64> = crate::detect::nvidia_power_limits().into_iter().map(|(k, v)| (k, v.1)).collect();
let _ = want;
shared.send(Cmd::PowerApplied(what, r, back));
});
}
}
}
/// "index, draw, gpu temp, mem temp, limit" every 5 s.
@ -1171,11 +1261,12 @@ impl Engine {
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
let p95 = v[((v.len() as f64 * 0.95) as usize).min(v.len() - 1)];
self.shared.log(&format!(
"stability: {}: draw p95 {:.0} W, max {:.0} W (cap {:.0} W), max GPU {:.0} C, max memory {:.0} C, {} samples",
"stability: {}: draw p95 {:.0} W, max {:.0} W, limit {:.0} W ({}), max GPU {:.0} C, max memory {:.0} C, {} samples",
c.name,
p95,
v[v.len() - 1],
c.power_limit_w,
if c.power_applied { format!("cap in force, {}% of default", c.power_pct) } else { "cap NOT applied".to_string() },
s.max_tgpu,
s.max_tmem,
v.len()
@ -1913,6 +2004,19 @@ impl Engine {
}
}
/// The watts a card's cap asks for: power_pct of the default limit, inside the card's min and max.
fn requested_watts(c: &CardState) -> f64 {
let pct = if c.power_pct == 0 { 80 } else { c.power_pct.clamp(60, 100) };
let mut w = c.power_default_w * pct as f64 / 100.0;
if c.power_min_w > 0.0 {
w = w.max(c.power_min_w);
}
if c.power_max_w > 0.0 {
w = w.min(c.power_max_w);
}
w.round()
}
/// Per-card session statistics for the stability line.
#[derive(Default)]
struct Stability {

1104
app/igneum-app/src/jobrun.rs Normal file

File diff suppressed because it is too large Load diff

833
app/igneum-app/src/jobs.rs Normal file
View file

@ -0,0 +1,833 @@
//! Signed remote jobs: the Mac publishes `igneum-jobs.json` with a detached Ed25519 signature next to the update
//! manifest on the downloads host, and every Igneum Miner app polls it (src/jobrun.rs, every 10 minutes). A job
//! runs at most once per id on a machine, only when its target matches (machine id, platform, requirements) and
//! it has not expired. Same key, same canonical JSON (sorted keys, no whitespace) and the same `.sig` scheme as the
//! update manifest (src/manifest.rs). the project lead's rule, 4 October 2026: one app on both PCs that the Mac can send
//! commands and files to over the line, so everything is tested and built without a person at the PC.
//!
//! This module is self-contained (serde_json and manifest.rs only), so the signer (src/bin/ota-sign.rs) includes it
//! with `#[path]` and validates what it signs with the code the app runs.
//!
//! File shape:
//! {
//! "published_at": "2026-10-04T15:00:00Z",
//! "jobs": [ {
//! "id": "shard-20261004-150000", "kind": "shard-benchmark", "title": "Shard proof run on the 5090",
//! "created_at": "2026-10-04T15:00:00Z", "expires_at": "2026-10-06T15:00:00Z",
//! "target": { "machine_ids": ["1ccfe586"] | "all", "platform": "windows" | "mac" | "any", "requires": ["wsl-prover"] },
//! "params": { ... per kind ... },
//! "report": "log-intake"
//! } ]
//! }
//!
//! Kinds and their params:
//! run script (the body), shell powershell|bash (default per platform), elevated, stop_miners_first,
//! timeout_minutes (default 60, at most 600)
//! fetch url (https), sha256, size, to (file name), dir jobs|prove|packs|updates (default jobs, which is
//! <app data>/app/jobs/<id>/), extract (tar -xf into the dir), fresh (empty extract_dir first), extract_dir
//! collect globs ["logs/app-*.log", ...] relative to the app data root (* and ? per path component), command
//! (its output goes into the report), label
//! restart what miners|node|app
//! update-now no params: the over-the-air check runs and a newer version installs at once
//! shard-benchmark zip_url, sha256, size, fixtures [shard fixture, block fixtures...], cap_minutes (90), distro
//! (Ubuntu-24.04), wsl_user
//! A job never writes outside the app data directory except through an explicit `run` script, which is the
//! operator's responsibility.
#![allow(dead_code)]
use crate::manifest;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const JOBS_FILE: &str = "igneum-jobs.json";
pub const KINDS: &[&str] = &["run", "fetch", "collect", "restart", "update-now", "shard-benchmark"];
/// Requirements the engine knows how to probe (src/jobrun.rs). An unknown requirement is never satisfied.
pub const KNOWN_REQUIRES: &[&str] = &["wsl", "wsl-prover", "nvidia"];
/// Named folders a `fetch` may write into, all under the app data root.
pub const FETCH_DIRS: &[&str] = &["jobs", "prove", "packs", "updates"];
pub const DEFAULT_RUN_TIMEOUT_MIN: u64 = 60;
pub const MAX_RUN_TIMEOUT_MIN: u64 = 600;
pub const DEFAULT_SHARD_CAP_MIN: u64 = 90;
#[derive(Clone, Debug, PartialEq, Default)]
pub struct Target {
/// Machine ids (16 hex) or their first 8 hex; empty with `all` set means every machine.
pub machine_ids: Vec<String>,
pub all: bool,
/// "windows" | "mac" | "linux" | "any"
pub platform: String,
pub requires: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Default)]
pub struct Job {
pub id: String,
pub kind: String,
pub title: String,
pub created_at: String,
pub expires_at: String,
pub expires_unix: u64,
pub target: Target,
pub params: Value,
pub report: String,
}
#[derive(Clone, Debug, PartialEq, Default)]
pub struct JobsFile {
pub published_at: String,
pub jobs: Vec<Job>,
}
impl Job {
/// Dashboard and report wording: the title, else the kind.
pub fn label(&self) -> String {
if self.title.trim().is_empty() { self.kind.clone() } else { self.title.trim().to_string() }
}
pub fn str_param(&self, k: &str) -> String {
self.params.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string()
}
pub fn bool_param(&self, k: &str) -> bool {
self.params.get(k).and_then(|v| v.as_bool()).unwrap_or(false)
}
pub fn u64_param(&self, k: &str) -> Option<u64> {
self.params.get(k).and_then(|v| v.as_u64())
}
pub fn list_param(&self, k: &str) -> Vec<String> {
match self.params.get(k) {
Some(Value::Array(a)) => a.iter().filter_map(|v| v.as_str()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
Some(Value::String(s)) => s.split_whitespace().map(|s| s.to_string()).collect(),
_ => vec![],
}
}
/// `run`: the script's timeout; `shard-benchmark`: the cap. Clamped to MAX_RUN_TIMEOUT_MIN.
pub fn timeout_minutes(&self) -> u64 {
let d = if self.kind == "shard-benchmark" { DEFAULT_SHARD_CAP_MIN } else { DEFAULT_RUN_TIMEOUT_MIN };
let v = self.u64_param("timeout_minutes").or_else(|| self.u64_param("cap_minutes")).unwrap_or(d);
v.clamp(1, MAX_RUN_TIMEOUT_MIN)
}
/// The run id under which the machine reports this job to the log intake.
pub fn run_id(&self, machine_id: &str) -> String {
format!("job-{}-{}", self.id, id8(machine_id))
}
}
/// The jobs file sits next to the update manifest: same folder, fixed name.
pub fn jobs_url_from_manifest(manifest_url: &str) -> String {
let u = manifest_url.trim();
if u.is_empty() {
return String::new();
}
match u.rfind('/') {
Some(i) => format!("{}/{}", &u[..i], JOBS_FILE),
None => String::new(),
}
}
pub fn id8(machine_id: &str) -> String {
machine_id.trim().to_ascii_lowercase().chars().take(8).collect()
}
fn valid_id(s: &str) -> bool {
!s.is_empty() && s.len() <= 64 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') && !s.starts_with('.')
}
/// "2026-10-04T15:00:00Z" (or with a fractional second, or "+00:00") to unix seconds; a plain number is unix already.
pub fn parse_time(s: &str) -> Option<u64> {
let s = s.trim();
if s.is_empty() {
return None;
}
if let Ok(n) = s.parse::<u64>() {
return Some(n);
}
let (date, time) = s.split_once('T')?;
let d: Vec<i64> = date.split('-').map(|p| p.parse().ok()).collect::<Option<Vec<_>>>()?;
if d.len() != 3 || !(1..=12).contains(&d[1]) || !(1..=31).contains(&d[2]) {
return None;
}
let time = time.trim_end_matches('Z');
let time = time.split('+').next()?;
let time = time.split('.').next()?;
let t: Vec<i64> = time.split(':').map(|p| p.parse().ok()).collect::<Option<Vec<_>>>()?;
if t.len() < 2 || t.len() > 3 || t[0] > 23 || t[1] > 59 {
return None;
}
let sec = if t.len() == 3 { t[2] } else { 0 };
if sec > 60 {
return None;
}
let days = days_from_civil(d[0], d[1], d[2]);
let unix = days * 86400 + t[0] * 3600 + t[1] * 60 + sec;
if unix < 0 { None } else { Some(unix as u64) }
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
/// Unix seconds to "YYYY-MM-DDTHH:MM:SSZ".
pub fn format_time(unix: u64) -> String {
let z = (unix / 86400) as i64 + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
let s = unix % 86400;
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", s / 3600, s % 3600 / 60, s % 60)
}
fn parse_target(v: Option<&Value>) -> Result<Target, String> {
let Some(v) = v else { return Err("target is missing".into()) };
let mut t = Target { platform: "any".into(), ..Default::default() };
match v.get("machine_ids") {
None => return Err("target.machine_ids is missing (a list of machine ids, or \"all\")".into()),
Some(Value::String(s)) if s == "all" => t.all = true,
Some(Value::Array(a)) => {
for m in a {
let m = m.as_str().ok_or("target.machine_ids holds a non-string")?.trim().to_ascii_lowercase();
if (m.len() != 8 && m.len() != 16) || !m.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("target.machine_ids: '{m}' is not 8 or 16 hex characters"));
}
t.machine_ids.push(m);
}
if t.machine_ids.is_empty() {
return Err("target.machine_ids is empty (use \"all\" for every machine)".into());
}
}
Some(_) => return Err("target.machine_ids must be a list or \"all\"".into()),
}
if let Some(p) = v.get("platform") {
let p = p.as_str().ok_or("target.platform is not a string")?.trim().to_ascii_lowercase();
if !p.is_empty() {
if !["windows", "mac", "linux", "any"].contains(&p.as_str()) {
return Err(format!("target.platform '{p}' is unknown"));
}
t.platform = p;
}
}
if let Some(r) = v.get("requires") {
let a = r.as_array().ok_or("target.requires is not a list")?;
for x in a {
let x = x.as_str().ok_or("target.requires holds a non-string")?.trim().to_string();
if !x.is_empty() {
t.requires.push(x);
}
}
}
Ok(t)
}
/// Parses the jobs file (after the signature was checked). Every job is validated; one bad job rejects the file,
/// so a typo on the Mac is caught by the signer before anything is published.
pub fn parse(text: &str) -> Result<JobsFile, String> {
let v: Value = serde_json::from_str(text).map_err(|e| format!("jobs file is not JSON: {e}"))?;
let s = |v: &Value, k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
let list = v.get("jobs").and_then(|j| j.as_array()).ok_or("jobs file has no \"jobs\" list")?;
let mut out = JobsFile { published_at: s(&v, "published_at"), jobs: Vec::new() };
let mut seen = std::collections::HashSet::new();
for (i, j) in list.iter().enumerate() {
let id = s(j, "id");
if !valid_id(&id) {
return Err(format!("job {i}: id '{id}' is not 1 to 64 of [A-Za-z0-9._-]"));
}
if !seen.insert(id.clone()) {
return Err(format!("job id '{id}' appears twice"));
}
let kind = s(j, "kind");
if !KINDS.contains(&kind.as_str()) {
return Err(format!("job {id}: kind '{kind}' is unknown (known: {})", KINDS.join(", ")));
}
let expires_at = s(j, "expires_at");
let expires_unix = parse_time(&expires_at).ok_or(format!("job {id}: expires_at '{expires_at}' is not a time"))?;
let created_at = s(j, "created_at");
if !created_at.is_empty() && parse_time(&created_at).is_none() {
return Err(format!("job {id}: created_at '{created_at}' is not a time"));
}
let target = parse_target(j.get("target")).map_err(|e| format!("job {id}: {e}"))?;
let params = match j.get("params") {
None | Some(Value::Null) => json!({}),
Some(p) if p.is_object() => p.clone(),
Some(_) => return Err(format!("job {id}: params is not an object")),
};
let job = Job { id: id.clone(), kind, title: s(j, "title"), created_at, expires_at, expires_unix, target, params, report: { let r = s(j, "report"); if r.is_empty() { "log-intake".into() } else { r } } };
validate_params(&job).map_err(|e| format!("job {id}: {e}"))?;
out.jobs.push(job);
}
Ok(out)
}
fn https_ok(url: &str) -> bool {
url.starts_with("https://") || url.starts_with("http://127.0.0.1:")
}
fn sha_ok(s: &str) -> bool {
s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
}
/// Per-kind checks of the params, so a job that cannot run is refused at signing time.
pub fn validate_params(job: &Job) -> Result<(), String> {
match job.kind.as_str() {
"run" => {
if job.str_param("script").trim().is_empty() {
return Err("run: params.script is empty".into());
}
let sh = job.str_param("shell");
if !sh.is_empty() && !["powershell", "bash"].contains(&sh.as_str()) {
return Err(format!("run: shell '{sh}' is not powershell or bash"));
}
}
"fetch" => {
if !https_ok(&job.str_param("url")) {
return Err("fetch: params.url is not https".into());
}
if !sha_ok(&job.str_param("sha256")) {
return Err("fetch: params.sha256 is not 64 hex characters".into());
}
let dir = job.str_param("dir");
if !dir.is_empty() && !FETCH_DIRS.contains(&dir.as_str()) {
return Err(format!("fetch: dir '{dir}' is not one of {}", FETCH_DIRS.join(", ")));
}
for k in ["to", "extract_dir"] {
let v = job.str_param(k);
if !v.is_empty() && safe_rel_path(&v).is_none() {
return Err(format!("fetch: {k} '{v}' is not a plain relative path"));
}
}
}
"collect" => {
let globs = job.list_param("globs");
if globs.is_empty() && job.str_param("command").trim().is_empty() {
return Err("collect: give params.globs or params.command".into());
}
for g in &globs {
if safe_rel_path(g).is_none() {
return Err(format!("collect: glob '{g}' is not a plain relative path"));
}
}
}
"restart" => {
let w = job.str_param("what");
if !["miners", "node", "app"].contains(&w.as_str()) {
return Err(format!("restart: what '{w}' is not miners, node or app"));
}
}
"update-now" => {}
"shard-benchmark" => {
let url = job.str_param("zip_url");
if !url.is_empty() && !https_ok(&url) {
return Err("shard-benchmark: zip_url is not https".into());
}
if !sha_ok(&job.str_param("sha256")) {
return Err("shard-benchmark: params.sha256 of the prove zip is not 64 hex characters".into());
}
for f in job.list_param("fixtures") {
if safe_rel_path(&f).is_none() || f.contains('/') || f.contains('\\') {
return Err(format!("shard-benchmark: fixture name '{f}' is not plain"));
}
}
}
_ => return Err(format!("kind '{}' is unknown", job.kind)),
}
Ok(())
}
/// Verifies the detached signature over the exact bytes, then parses.
pub fn verify_and_parse(bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result<JobsFile, String> {
manifest::verify_signature(bytes, sig_hex, pub_hex).map_err(|_| "jobs file signature does not verify".to_string())?;
let text = std::str::from_utf8(bytes).map_err(|_| "jobs file is not UTF-8")?;
parse(text)
}
// ---- targeting ----------------------------------------------------------------------------------------------------
pub fn targets_machine(t: &Target, machine_id: &str) -> bool {
if t.all {
return true;
}
let full = machine_id.trim().to_ascii_lowercase();
let short = id8(machine_id);
t.machine_ids.iter().any(|m| *m == full || *m == short)
}
pub fn targets_platform(t: &Target, platform: &str) -> bool {
t.platform.is_empty() || t.platform == "any" || t.platform == platform
}
pub fn expired(job: &Job, now: u64) -> bool {
now > job.expires_unix
}
#[derive(Clone, Debug, PartialEq)]
pub enum Eligibility {
Run,
Expired,
OtherMachine,
OtherPlatform,
/// Requirements this machine does not meet (today; checked again at the next poll until the job expires).
Needs(Vec<String>),
}
/// Whether this machine runs the job now. `have` answers one requirement at a time (the engine probes WSL,
/// nvidia-smi and the prover toolchain); an unknown requirement is never met.
pub fn eligibility(job: &Job, machine_id: &str, platform: &str, now: u64, have: &dyn Fn(&str) -> bool) -> Eligibility {
if expired(job, now) {
return Eligibility::Expired;
}
if !targets_machine(&job.target, machine_id) {
return Eligibility::OtherMachine;
}
if !targets_platform(&job.target, platform) {
return Eligibility::OtherPlatform;
}
let missing: Vec<String> = job.target.requires.iter().filter(|r| !KNOWN_REQUIRES.contains(&r.as_str()) || !have(r)).cloned().collect();
if missing.is_empty() { Eligibility::Run } else { Eligibility::Needs(missing) }
}
// ---- the once-only ledger (jobs-state.json in the app data dir) -----------------------------------------------------
#[derive(Clone, Debug, PartialEq, Default)]
pub struct Record {
pub id: String,
pub kind: String,
pub title: String,
/// running | done | failed | timeout | aborted
pub status: String,
pub started_at: u64,
pub finished_at: u64,
pub exit: i64,
pub run_id: String,
pub summary: String,
pub uploaded: bool,
}
impl Record {
pub fn to_json(&self) -> Value {
json!({ "id": self.id, "kind": self.kind, "title": self.title, "status": self.status, "started_at": self.started_at, "finished_at": self.finished_at, "exit": self.exit, "run_id": self.run_id, "summary": self.summary, "uploaded": self.uploaded })
}
fn from_json(id: &str, v: &Value) -> Record {
let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
Record {
id: id.to_string(),
kind: s("kind"),
title: s("title"),
status: s("status"),
started_at: v.get("started_at").and_then(|x| x.as_u64()).unwrap_or(0),
finished_at: v.get("finished_at").and_then(|x| x.as_u64()).unwrap_or(0),
exit: v.get("exit").and_then(|x| x.as_i64()).unwrap_or(0),
run_id: s("run_id"),
summary: s("summary"),
uploaded: v.get("uploaded").and_then(|x| x.as_bool()).unwrap_or(false),
}
}
}
/// Every job id this machine ever started, with its outcome. A job id in the ledger never runs again, whatever
/// its status: a crash mid-job counts as a run (the entry is marked aborted at the next start).
#[derive(Clone, Debug, Default)]
pub struct Ledger {
pub records: BTreeMap<String, Record>,
}
impl Ledger {
pub fn load(path: &Path) -> Ledger {
let mut l = Ledger::default();
let Ok(text) = std::fs::read_to_string(path) else { return l };
let Ok(v) = serde_json::from_str::<Value>(&text) else { return l };
if let Some(m) = v.get("jobs").and_then(|j| j.as_object()) {
for (id, r) in m {
l.records.insert(id.clone(), Record::from_json(id, r));
}
}
l
}
pub fn to_json(&self) -> Value {
let m: serde_json::Map<String, Value> = self.records.iter().map(|(k, r)| (k.clone(), r.to_json())).collect();
json!({ "jobs": m })
}
pub fn save(&self, path: &Path) -> std::io::Result<()> {
if let Some(d) = path.parent() {
let _ = std::fs::create_dir_all(d);
}
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, serde_json::to_string_pretty(&self.to_json()).unwrap_or_default())?;
std::fs::rename(&tmp, path)
}
pub fn seen(&self, id: &str) -> bool {
self.records.contains_key(id)
}
/// Marks a job as started (status running). False when it was seen before: the caller must not run it.
pub fn start(&mut self, job: &Job, run_id: &str, now: u64) -> bool {
if self.seen(&job.id) {
return false;
}
self.records.insert(job.id.clone(), Record { id: job.id.clone(), kind: job.kind.clone(), title: job.label(), status: "running".into(), started_at: now, run_id: run_id.to_string(), ..Default::default() });
true
}
pub fn finish(&mut self, id: &str, status: &str, exit: i64, summary: &str, uploaded: bool, now: u64) {
if let Some(r) = self.records.get_mut(id) {
r.status = status.to_string();
r.exit = exit;
r.summary = summary.chars().take(400).collect();
r.uploaded = uploaded;
r.finished_at = now;
}
}
pub fn set_uploaded(&mut self, id: &str, uploaded: bool) {
if let Some(r) = self.records.get_mut(id) {
r.uploaded = uploaded;
}
}
/// On start: anything still "running" died with the previous engine. Returns the ids marked aborted.
pub fn abandon_running(&mut self, now: u64) -> Vec<String> {
let mut out = Vec::new();
for r in self.records.values_mut() {
if r.status == "running" {
r.status = "aborted".into();
r.finished_at = now;
if r.summary.is_empty() {
r.summary = "the app stopped while the job ran".into();
}
out.push(r.id.clone());
}
}
out
}
/// The newest `n` records, newest first.
pub fn history(&self, n: usize) -> Vec<Record> {
let mut v: Vec<Record> = self.records.values().cloned().collect();
v.sort_by(|a, b| b.started_at.cmp(&a.started_at).then(b.id.cmp(&a.id)));
v.truncate(n);
v
}
}
// ---- helpers the runner and the tests share -------------------------------------------------------------------------
/// The JSON summary that heads every report upload. One line, so the reader (tools/jobs.mjs) parses the first line.
pub fn summary_line(job: &Job, machine_id: &str, host: &str, status: &str, exit: i64, started: u64, finished: u64, summary: &str, results: &[String], extra: Value) -> String {
let mut v = json!({
"job": job.id, "kind": job.kind, "title": job.label(), "machine_id": machine_id, "machine": host, "run_id": job.run_id(machine_id),
"status": status, "exit": exit, "started_at": format_time(started), "finished_at": if finished > 0 { format_time(finished) } else { String::new() },
"duration_s": finished.saturating_sub(started), "summary": summary, "results": results,
});
if let (Some(a), Some(b)) = (v.as_object_mut(), extra.as_object()) {
for (k, x) in b {
a.insert(k.clone(), x.clone());
}
}
format!("SUMMARY {}", v)
}
/// Lines a prover or benchmark prints for the bench log: RESULT and STAGE lines, plus BUILD FAILED.
pub fn result_lines(text: &str) -> Vec<String> {
text.lines().map(|l| l.trim_end()).filter(|l| l.starts_with("RESULT") || l.starts_with("STAGE") || l.starts_with("BUILD FAILED")).map(|l| l.to_string()).collect()
}
/// C:\Users\Admin\AppData\Local\igneum\prove -> /mnt/c/Users/Admin/AppData/Local/igneum/prove (WSL's default mount).
pub fn to_wsl_path(win: &str) -> Option<String> {
let w = win.trim().trim_start_matches("\\\\?\\");
let mut chars = w.chars();
let drive = chars.next()?;
if !drive.is_ascii_alphabetic() || chars.next()? != ':' {
return None;
}
let rest: String = chars.collect::<String>().replace('\\', "/");
Some(format!("/mnt/{}{}", drive.to_ascii_lowercase(), if rest.starts_with('/') { rest } else { format!("/{rest}") }))
}
/// A relative path with no "..", no drive or root, and no empty components; "/" and "\" both separate.
pub fn safe_rel_path(s: &str) -> Option<PathBuf> {
let s = s.trim();
if s.is_empty() || s.starts_with('/') || s.starts_with('\\') || s.contains(':') || s.contains('\0') {
return None;
}
let mut p = PathBuf::new();
for c in s.split(|ch| ch == '/' || ch == '\\') {
if c.is_empty() || c == "." || c == ".." {
return None;
}
p.push(c);
}
Some(p)
}
/// `*` (any run) and `?` (one character) within one path component.
pub fn glob_match(pattern: &str, name: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let n: Vec<char> = name.chars().collect();
fn go(p: &[char], n: &[char]) -> bool {
match (p.first(), n.first()) {
(None, None) => true,
(Some('*'), _) => go(&p[1..], n) || (!n.is_empty() && go(p, &n[1..])),
(Some('?'), Some(_)) => go(&p[1..], &n[1..]),
(Some(a), Some(b)) if a.eq_ignore_ascii_case(b) => go(&p[1..], &n[1..]),
_ => false,
}
}
go(&p, &n)
}
/// Files under `root` matching a relative glob (components with * and ?), as absolute paths, sorted.
pub fn glob_files(root: &Path, pattern: &str) -> Vec<PathBuf> {
let Some(rel) = safe_rel_path(pattern) else { return vec![] };
let comps: Vec<String> = rel.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect();
let mut cur = vec![root.to_path_buf()];
for (i, c) in comps.iter().enumerate() {
let last = i + 1 == comps.len();
let mut next = Vec::new();
for d in &cur {
let Ok(rd) = std::fs::read_dir(d) else { continue };
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if !glob_match(c, &name) {
continue;
}
let p = e.path();
if last {
if p.is_file() {
next.push(p);
}
} else if p.is_dir() {
next.push(p);
}
}
}
cur = next;
if cur.is_empty() {
break;
}
}
cur.sort();
cur
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
const SAMPLE: &str = r#"{"jobs":[{"created_at":"2026-10-04T15:00:00Z","expires_at":"2026-10-06T15:00:00Z","id":"shard-20261004-150000","kind":"shard-benchmark","params":{"cap_minutes":90,"fixtures":["block-338-shard1","block-341-shards2","block-344-shards4"],"sha256":"5e7b56f5d71ae3eeb46dd1cb3b203c8be013ce27372ac5c93ea19e9e55950373","size":286438,"zip_url":"https://dl.igneum.network/dl/t/igneum-prove-wsl2.zip"},"report":"log-intake","target":{"machine_ids":["1ccfe586"],"platform":"windows","requires":["wsl-prover"]},"title":"Shard proof run on the 5090"},{"expires_at":"2026-10-05T00:00:00Z","id":"collect-1","kind":"collect","params":{"globs":["logs/app-*.log"]},"target":{"machine_ids":"all"}}],"published_at":"2026-10-04T15:00:00Z"}"#;
fn key() -> (SigningKey, String) {
let sk = SigningKey::from_bytes(&[3u8; 32]);
let pk = manifest::hex_encode(sk.verifying_key().as_bytes());
(sk, pk)
}
#[test]
fn parses_jobs() {
let f = parse(SAMPLE).unwrap();
assert_eq!(f.published_at, "2026-10-04T15:00:00Z");
assert_eq!(f.jobs.len(), 2);
let j = &f.jobs[0];
assert_eq!(j.id, "shard-20261004-150000");
assert_eq!(j.kind, "shard-benchmark");
assert_eq!(j.label(), "Shard proof run on the 5090");
assert_eq!(j.target.machine_ids, vec!["1ccfe586"]);
assert_eq!(j.target.platform, "windows");
assert_eq!(j.target.requires, vec!["wsl-prover"]);
assert_eq!(j.expires_unix, parse_time("2026-10-06T15:00:00Z").unwrap());
assert_eq!(j.list_param("fixtures").len(), 3);
assert_eq!(j.timeout_minutes(), 90);
assert_eq!(j.run_id("1ccfe586aabbccdd"), "job-shard-20261004-150000-1ccfe586");
let c = &f.jobs[1];
assert!(c.target.all);
assert_eq!(c.target.platform, "any");
assert_eq!(c.label(), "collect");
assert_eq!(c.report, "log-intake");
assert_eq!(c.timeout_minutes(), DEFAULT_RUN_TIMEOUT_MIN);
}
#[test]
fn bad_jobs_are_refused() {
let base = |extra: &str| format!(r#"{{"jobs":[{{"id":"a","kind":"run","expires_at":"2026-10-06T15:00:00Z","target":{{"machine_ids":"all"}},"params":{{"script":"echo hi"}}{extra}}}]}}"#);
assert!(parse(&base("")).is_ok());
assert!(parse("nope").unwrap_err().contains("not JSON"));
assert!(parse(r#"{"x":1}"#).unwrap_err().contains("jobs"));
assert!(parse(&base("").replace("\"kind\":\"run\"", "\"kind\":\"dance\"")).unwrap_err().contains("unknown"));
assert!(parse(&base("").replace("\"id\":\"a\"", "\"id\":\"../x\"")).unwrap_err().contains("id"));
assert!(parse(&base("").replace("2026-10-06T15:00:00Z", "soon")).unwrap_err().contains("expires_at"));
assert!(parse(&base("").replace("\"machine_ids\":\"all\"", "\"machine_ids\":[\"zz\"]")).unwrap_err().contains("hex"));
assert!(parse(&base("").replace("\"machine_ids\":\"all\"", "\"machine_ids\":[]")).unwrap_err().contains("empty"));
assert!(parse(&base("").replace("\"target\":{\"machine_ids\":\"all\"}", "\"target\":{\"machine_ids\":\"all\",\"platform\":\"amiga\"}")).unwrap_err().contains("platform"));
assert!(parse(&base("").replace("\"script\":\"echo hi\"", "\"script\":\"\"")).unwrap_err().contains("script"));
// a duplicate id
let two = base("").replace("]}", ",{\"id\":\"a\",\"kind\":\"update-now\",\"expires_at\":\"2026-10-06T15:00:00Z\",\"target\":{\"machine_ids\":\"all\"}}]}");
assert!(parse(&two).unwrap_err().contains("twice"));
// per-kind params
let j = |kind: &str, params: &str| parse(&format!(r#"{{"jobs":[{{"id":"a","kind":"{kind}","expires_at":"2026-10-06T15:00:00Z","target":{{"machine_ids":"all"}},"params":{params}}}]}}"#));
assert!(j("fetch", r#"{"url":"http://x/a.zip","sha256":"aa"}"#).unwrap_err().contains("https"));
assert!(j("fetch", r#"{"url":"https://x/a.zip","sha256":"aa"}"#).unwrap_err().contains("sha256"));
assert!(j("fetch", &format!(r#"{{"url":"https://x/a.zip","sha256":"{}","dir":"etc"}}"#, "a".repeat(64))).unwrap_err().contains("dir"));
assert!(j("fetch", &format!(r#"{{"url":"https://x/a.zip","sha256":"{}","to":"../a"}}"#, "a".repeat(64))).unwrap_err().contains("relative"));
assert!(j("fetch", &format!(r#"{{"url":"https://x/a.zip","sha256":"{}","dir":"prove","extract":true}}"#, "a".repeat(64))).is_ok());
assert!(j("collect", r#"{}"#).unwrap_err().contains("globs"));
assert!(j("collect", r#"{"globs":["/etc/passwd"]}"#).unwrap_err().contains("relative"));
assert!(j("collect", r#"{"command":"nvidia-smi"}"#).is_ok());
assert!(j("restart", r#"{"what":"everything"}"#).unwrap_err().contains("restart"));
assert!(j("restart", r#"{"what":"miners"}"#).is_ok());
assert!(j("update-now", r#"{}"#).is_ok());
assert!(j("shard-benchmark", r#"{}"#).unwrap_err().contains("sha256"));
assert!(j("shard-benchmark", &format!(r#"{{"sha256":"{}","fixtures":["../x"]}}"#, "b".repeat(64))).unwrap_err().contains("fixture"));
assert!(j("run", r#"{"script":"ls","shell":"zsh"}"#).unwrap_err().contains("shell"));
}
#[test]
fn signature_verifies_and_tampering_fails() {
let (sk, pk) = key();
let sig = manifest::hex_encode(&sk.sign(SAMPLE.as_bytes()).to_bytes());
let f = verify_and_parse(SAMPLE.as_bytes(), &sig, &pk).unwrap();
assert_eq!(f.jobs.len(), 2);
// the id changed after signing: refused before parsing
let tampered = SAMPLE.replace("collect-1", "collect-2");
assert_eq!(verify_and_parse(tampered.as_bytes(), &sig, &pk).unwrap_err(), "jobs file signature does not verify");
// the OTA key cannot be swapped for another
let other = manifest::hex_encode(SigningKey::from_bytes(&[4u8; 32]).verifying_key().as_bytes());
assert!(verify_and_parse(SAMPLE.as_bytes(), &sig, &other).is_err());
assert!(verify_and_parse(SAMPLE.as_bytes(), "zz", &pk).is_err());
}
#[test]
fn times() {
assert_eq!(parse_time("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(parse_time("2026-10-04T11:17:47Z"), Some(1_791_112_667));
assert_eq!(parse_time("2026-10-04T11:17:47.250Z"), Some(1_791_112_667));
assert_eq!(parse_time("2026-10-04T11:17:47+00:00"), Some(1_791_112_667));
assert_eq!(parse_time("2026-10-04T11:17Z"), Some(1_791_112_620));
assert_eq!(parse_time("1791112667"), Some(1_791_112_667));
assert_eq!(parse_time("2026-13-04T11:17:47Z"), None);
assert_eq!(parse_time("yesterday"), None);
assert_eq!(parse_time(""), None);
assert_eq!(format_time(1_791_112_667), "2026-10-04T11:17:47Z");
assert_eq!(format_time(0), "1970-01-01T00:00:00Z");
for t in [1u64, 951_782_400, 1_709_164_800, 4_102_444_800] {
assert_eq!(parse_time(&format_time(t)), Some(t));
}
}
#[test]
fn targeting() {
let f = parse(SAMPLE).unwrap();
let shard = &f.jobs[0];
let all = &f.jobs[1];
let now = parse_time("2026-10-04T16:00:00Z").unwrap();
let have_all = |_: &str| true;
let have_none = |_: &str| false;
// the full 16-hex id or its first 8 hex both match
assert_eq!(eligibility(shard, "1ccfe586aabbccdd", "windows", now, &have_all), Eligibility::Run);
assert_eq!(eligibility(shard, "1CCFE586", "windows", now, &have_all), Eligibility::Run);
assert_eq!(eligibility(shard, "ae432dc7aabbccdd", "windows", now, &have_all), Eligibility::OtherMachine);
assert_eq!(eligibility(shard, "1ccfe586aabbccdd", "mac", now, &have_all), Eligibility::OtherPlatform);
assert_eq!(eligibility(shard, "1ccfe586aabbccdd", "windows", now, &have_none), Eligibility::Needs(vec!["wsl-prover".into()]));
// expiry: the second after expires_at
assert_eq!(eligibility(shard, "1ccfe586aabbccdd", "windows", shard.expires_unix, &have_all), Eligibility::Run);
assert_eq!(eligibility(shard, "1ccfe586aabbccdd", "windows", shard.expires_unix + 1, &have_all), Eligibility::Expired);
// "all", any platform, no requirements
assert_eq!(eligibility(all, "ae432dc7aabbccdd", "mac", now, &have_none), Eligibility::Run);
// an unknown requirement is never met, even when the probe says yes
let mut j = all.clone();
j.target.requires = vec!["quantum-link".into()];
assert_eq!(eligibility(&j, "x", "mac", now, &have_all), Eligibility::Needs(vec!["quantum-link".into()]));
// a known one the probe meets
j.target.requires = vec!["nvidia".into()];
assert_eq!(eligibility(&j, "x", "mac", now, &have_all), Eligibility::Run);
}
#[test]
fn once_only_ledger() {
let f = parse(SAMPLE).unwrap();
let dir = std::env::temp_dir().join(format!("igneum-jobs-test-{}", std::process::id()));
let path = dir.join("jobs-state.json");
let mut l = Ledger::load(&path);
assert!(!l.seen("shard-20261004-150000"));
assert!(l.start(&f.jobs[0], "job-shard-20261004-150000-1ccfe586", 100));
// the same id never starts twice, whatever happened to the first run
assert!(!l.start(&f.jobs[0], "x", 101));
l.finish("shard-20261004-150000", "done", 0, "RESULT ok", true, 200);
l.save(&path).unwrap();
let l2 = Ledger::load(&path);
assert!(l2.seen("shard-20261004-150000"));
assert!(!l2.seen("collect-1"));
let r = &l2.records["shard-20261004-150000"];
assert_eq!((r.status.as_str(), r.exit, r.uploaded, r.started_at, r.finished_at), ("done", 0, true, 100, 200));
assert_eq!(r.summary, "RESULT ok");
assert_eq!(r.title, "Shard proof run on the 5090");
// a run that died with the app is marked aborted on the next start and stays done-for-good
let mut l3 = l2.clone();
assert!(l3.start(&f.jobs[1], "job-collect-1-ae432dc7", 300));
l3.save(&path).unwrap();
let mut l4 = Ledger::load(&path);
assert_eq!(l4.abandon_running(400), vec!["collect-1".to_string()]);
assert_eq!(l4.records["collect-1"].status, "aborted");
assert!(!l4.start(&f.jobs[1], "x", 500));
let h = l4.history(10);
assert_eq!(h.len(), 2);
assert_eq!(h[0].id, "collect-1"); // newest first
assert_eq!(l4.history(1).len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn helpers() {
assert_eq!(jobs_url_from_manifest("https://dl.igneum.network/dl/tok/igneum-app-latest.json"), "https://dl.igneum.network/dl/tok/igneum-jobs.json");
assert_eq!(jobs_url_from_manifest(""), "");
assert_eq!(id8("1ccfe586aabbccdd"), "1ccfe586");
assert_eq!(to_wsl_path(r"C:\Users\Admin\AppData\Local\igneum\prove"), Some("/mnt/c/Users/Admin/AppData/Local/igneum/prove".into()));
assert_eq!(to_wsl_path(r"\\?\D:\x"), Some("/mnt/d/x".into()));
assert_eq!(to_wsl_path("/Users/x"), None);
assert_eq!(safe_rel_path("logs/app-*.log").unwrap(), PathBuf::from("logs").join("app-*.log"));
assert!(safe_rel_path("../x").is_none());
assert!(safe_rel_path("a/../b").is_none());
assert!(safe_rel_path("/etc").is_none());
assert!(safe_rel_path("C:\\x").is_none());
assert!(safe_rel_path("").is_none());
assert!(glob_match("app-*.log", "app-20661-120000.log"));
assert!(glob_match("*.json", "block-344-shards4-cuda-x.json"));
assert!(!glob_match("*.json", "x.log"));
assert!(glob_match("task-?.ps1", "task-7.ps1"));
assert!(!glob_match("task-?.ps1", "task-77.ps1"));
assert!(glob_match("*", ""));
let lines = result_lines("building\nSTAGE execute 2026-10-04T15:00:00Z\nnoise\nRESULT core prove 1.4 s\nBUILD FAILED\n");
assert_eq!(lines, vec!["STAGE execute 2026-10-04T15:00:00Z", "RESULT core prove 1.4 s", "BUILD FAILED"]);
let f = parse(SAMPLE).unwrap();
let s = summary_line(&f.jobs[0], "1ccfe586aabbccdd", "DESKTOP-X", "done", 0, 100, 160, "ok", &["RESULT a".into()], json!({ "uploaded_files": 3 }));
assert!(s.starts_with("SUMMARY {"));
let v: Value = serde_json::from_str(s.trim_start_matches("SUMMARY ")).unwrap();
assert_eq!(v["run_id"], "job-shard-20261004-150000-1ccfe586");
assert_eq!(v["duration_s"], 60);
assert_eq!(v["uploaded_files"], 3);
assert_eq!(v["results"][0], "RESULT a");
}
#[test]
fn glob_files_walks_components() {
let dir = std::env::temp_dir().join(format!("igneum-glob-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("logs")).unwrap();
std::fs::create_dir_all(dir.join("prove").join("results")).unwrap();
std::fs::write(dir.join("logs").join("app-1.log"), "a").unwrap();
std::fs::write(dir.join("logs").join("node-1.log"), "n").unwrap();
std::fs::write(dir.join("prove").join("results").join("block-344-cuda.json"), "{}").unwrap();
let a = glob_files(&dir, "logs/app-*.log");
assert_eq!(a.len(), 1);
assert!(a[0].ends_with("app-1.log"));
assert_eq!(glob_files(&dir, "logs/*.log").len(), 2);
assert_eq!(glob_files(&dir, "prove/*/*.json").len(), 1);
assert_eq!(glob_files(&dir, "prove/*.json").len(), 0);
assert_eq!(glob_files(&dir, "../*").len(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -112,10 +112,13 @@ fn main() {
let stdin = std::io::stdin();
for line in stdin.lock().lines() {
let Ok(l) = line else { break };
match l.trim() {
let t = l.trim();
match t {
"quit" => shared.send(engine::Cmd::Quit),
"pause" => shared.send(engine::Cmd::Pause),
"resume" => shared.send(engine::Cmd::Resume),
"elevated ok" => shared.send(engine::Cmd::ElevatedDone(Ok(()))),
_ if t.starts_with("elevated fail") => shared.send(engine::Cmd::ElevatedDone(Err(t.trim_start_matches("elevated fail").trim_start_matches(':').trim().to_string()))),
_ => {}
}
}

View file

@ -244,6 +244,10 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
Err("only http(s) links open".into())
}
}
"/api/power/apply" => {
shared.send(Cmd::ApplyPower);
Ok(json!({ "ok": true }))
}
"/api/clock/sync" => {
shared.send(Cmd::ClockSync);
Ok(json!({ "ok": true }))

View file

@ -168,6 +168,9 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
.cards.compact .power input[type=range]{width:80px}
.gpu-tile .m.tele .warm,.gpu-tile .m.tele .warm b{color:var(--molten)}
.gpu-tile .m.tele .hot,.gpu-tile .m.tele .hot b{color:var(--ember)}
.gpu-tile .msg.ok,.gpu-row .msg.ok{color:var(--molten)}
.gpu-row .msg.hot,.gpu-tile .msg.hot{color:var(--ember)}
.gpu-tile .msg .btn.tiny,.gpu-row .msg .btn.tiny{margin-left:6px;vertical-align:middle}
.gpu-tile .msg.warm{color:var(--molten)}
.gpu-tile .msg.hot{color:var(--ember)}

View file

@ -98,6 +98,15 @@
if (!state) return;
$('s-address').textContent = state.address.display || 'not set';
renderCardRows($('s-cards'), state.mining.cards);
state.mining.cards.forEach(function (cd) {
if (cd.vendor !== 'nvidia' || !(cd.power_default_w > 0)) return;
var row = $('s-cards').querySelector('.gpu-row[data-key="' + cd.key.replace(/"/g, '\\"') + '"] .info');
if (!row) return;
var want = Math.round(cd.power_default_w * (cd.power_pct || 80) / 100);
var d = document.createElement('div'); d.className = cd.power_applied ? 'msg ok' : 'msg hot';
d.innerHTML = cd.power_applied ? 'power cap: ' + want + ' W applied' : 'power cap NOT applied (needs the administrator prompt) <button class="btn tiny" data-power-retry="1">Retry</button>';
row.appendChild(d);
});
$('s-name').value = state.display_name || '';
$('s-mid').textContent = (state.machine_id || '').slice(0, 8);
$('s-vote').checked = state.settings.vote;
@ -287,9 +296,17 @@
'<span>GPU <b>' + (cd.temp_gpu ? Math.round(cd.temp_gpu) + ' °C' : 'n/a') + '</b></span>' +
'<span class="' + memCls + '">memory <b>' + (cd.temp_mem ? Math.round(cd.temp_mem) + ' °C' : 'n/a') + '</b></span></div>';
if (memCls) h += '<div class="msg ' + memCls + '">memory ' + Math.round(cd.temp_mem) + ' °C: card throttling or at risk</div>';
if (cd.power_note) h += '<div class="msg">' + esc(cd.power_note) + '</div>';
if (cd.power_default_w > 0) {
var want = Math.round(cd.power_default_w * (cd.power_pct || 80) / 100);
if (cd.power_applied) h += '<div class="msg ok">power cap: ' + want + ' W applied</div>';
else h += '<div class="msg hot">power cap NOT applied (needs the administrator prompt)' + (cd.power_note && cd.power_note.indexOf('wanted') > 0 ? ' · ' + esc(cd.power_note.replace(/^.*: card reports/, 'card reports')) : '') + ' <button class="btn tiny" data-power-retry="1">Retry</button></div>';
}
return h;
}
document.addEventListener('click', function (e) {
var b = e.target.closest('[data-power-retry]'); if (!b) return;
api('api/power/apply', {}).then(function () { toast('Administrator prompt: allow it to set the cap'); });
});
var lastEventsKey = '';
function renderDashboard(s) {
var m = s.mining, n = s.node, p = s.program, f = s.finality;

View file

@ -107,6 +107,31 @@ static void applyState(const std::string& j) {
setTray(tip);
}
// The engine asks for an elevated step (the NVIDIA power cap): this process has a UI context, so the UAC prompt shows.
// Runs cmd /c <line> as administrator, waits, and answers on the engine's stdin.
static void runElevated(std::wstring line) {
std::thread([line] {
std::wstring params = L"/c " + line;
SHELLEXECUTEINFOW sei = { sizeof(sei) };
sei.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI;
sei.lpVerb = L"runas";
sei.lpFile = L"cmd.exe";
sei.lpParameters = params.c_str();
sei.nShow = SW_HIDE;
if (!ShellExecuteExW(&sei) || !sei.hProcess) {
DWORD err = GetLastError();
sendEngine(err == ERROR_CANCELLED ? "elevated fail: the administrator prompt was cancelled" : "elevated fail: could not start the elevated step");
return;
}
WaitForSingleObject(sei.hProcess, 120000);
DWORD code = 1;
GetExitCodeProcess(sei.hProcess, &code);
CloseHandle(sei.hProcess);
if (code == 0) sendEngine("elevated ok");
else { char buf[64]; sprintf_s(buf, "elevated fail: exit code %lu", code); sendEngine(buf); }
}).detach();
}
static void openInBrowser(const std::wstring& url) {
ShellExecuteW(nullptr, L"open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
}
@ -272,6 +297,8 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
else if (!g_webview && g_status.find(L"WebView2") != std::wstring::npos && !g_hintShown) { openInBrowser(g_url); g_hintShown = true; }
} else if (line->rfind("STATE ", 0) == 0) {
applyState(line->substr(6));
} else if (line->rfind("ELEVATE ", 0) == 0) {
runElevated(widen(line->substr(8)));
} else if (line->rfind("FATAL ", 0) == 0) {
g_status = widen(line->substr(6));
repaintStatus();