Merge commit '032e1f87' into release-0.3.19

# Conflicts:
#	app/igneum-app/src/update.rs
This commit is contained in:
igneum-labs 2026-10-07 09:39:34 +00:00
commit 4570f92ab9
37 changed files with 1203 additions and 46 deletions

View file

@ -48,6 +48,13 @@ pub enum Cmd {
WorkerBuilt(usize, Result<PathBuf, String>),
/// local minus the latest block's timestamp, seconds (from the node's EVM RPC)
ClockSample(f64),
/// another node holds the app's ports: what it answered (src/extnode.rs)
ExternalNode(crate::extnode::Check),
/// the node's own description over RPC (igneum_getNodeInfo), every 30 s
NodeInfo(crate::extnode::Check),
/// the network's view for the merge check (src/merge.rs): the highest blue score the observer shows, and the unix
/// ms of the newest block it marks as ours
NetView { sink_blue: u64, ours_ts_ms: Option<i64> },
/// local minus an HTTPS Date header, seconds; None when the check failed
ClockHttps(Option<f64>),
ClockCheck,
@ -120,6 +127,7 @@ impl Shared {
st.setup_done = settings.setup_done;
st.phase = if settings.setup_done { "dashboard".into() } else { "welcome".into() };
st.node.state = "stopped".into();
st.node.tip_age_s = -1.0;
st.mining.state = "idle".into();
st.mining.paused = settings.paused;
st.mining.accepted_total = settings.accepted_total;
@ -534,6 +542,18 @@ pub struct Engine {
lines_tx: Sender<Line>,
node: Option<Proc>,
node_external: bool,
/// the merge check (src/merge.rs): the network's sinks as last seen, when one of our blocks last appeared in the
/// network's view, the merge depth from the node's params, when mining with a synced node began, the last poll
merge_sinks: Vec<u64>,
merge_ours_seen: Option<Instant>,
merge_depth: u64,
merge_mining_since: Option<Instant>,
merge_view_at: Instant,
/// the ports are held by a node this app refused (other rules or network): re-checked, the app takes the ports when it leaves
node_refused: bool,
/// when the other node's port stopped answering (engine seconds), None while it answers
external_gone_since: Option<f64>,
node_info_at: Instant,
node_restart_at: Option<Instant>,
/// resume rule (5 October 2026): when due, every enabled card must be mining or its name goes to the log
resume_check_at: Option<Instant>,
@ -653,6 +673,14 @@ impl Engine {
lines_tx,
node: None,
node_external: false,
merge_sinks: Vec::new(),
merge_ours_seen: None,
merge_depth: 0,
merge_mining_since: None,
merge_view_at: now,
node_refused: false,
external_gone_since: None,
node_info_at: Instant::now() - Duration::from_secs(25),
node_restart_at: None,
resume_check_at: None,
node_started_at: now,
@ -941,6 +969,33 @@ impl Engine {
}
}
}
Cmd::ExternalNode(check) => self.external_node_decided(check),
Cmd::NetView { sink_blue, ours_ts_ms } => {
self.merge_sinks = vec![sink_blue];
if let Some(ms) = ours_ts_ms {
let age = (crate::platform::unix_now_f() - ms as f64 / 1000.0).max(0.0);
self.merge_ours_seen = Some(Instant::now() - Duration::from_secs_f64(age.min(86_400.0)));
}
}
Cmd::NodeInfo(info) => {
// the merge depth is compiled per network (not in params): blockrate.mergeDepth when the node says, else 3,600
if let Some(d) = info.merge_depth { self.merge_depth = d; }
if info.pow_engine.as_deref() == Some("stub") {
let first = self.st().node.state != "stub";
let mut st = self.st();
st.node.state = "stub".into();
st.node.synced = false;
st.node.message = "this node was built without the mining engine (stub) and refuses every real block".into();
drop(st);
if first { self.shared.event("error", "the node runs the stub engine, not igneum-pow: it refuses every real block. Reinstall the app (ledger N5)"); }
}
let mut st = self.st();
if let Some(d) = info.digest.filter(|d| d.len() >= 16) {
if st.node.consensus_digest != d { st.node.consensus_digest = d; }
st.node.digest_source = "rpc".into();
}
if let Some(v) = info.version { if !v.is_empty() && st.node.version.is_empty() { st.node.version = v; } }
}
Cmd::ClockSample(d) => {
self.clock_samples.push(d);
if self.clock_samples.len() > 9 {
@ -1381,14 +1436,22 @@ impl Engine {
let _ = std::fs::create_dir_all(&self.shared.runtime.log_dir);
self.apply_power_limits("start");
if port_open(self.shared.runtime.rpc_port) {
self.node_external = true;
self.shared.event("info", &format!("a node already answers on 127.0.0.1:{}; using it (it is not stopped by this app)", self.shared.runtime.rpc_port));
let mut st = self.st();
st.node.state = "syncing".into();
st.node.message = "external node".into();
st.proving.verifier_reason = "external node: the app did not start it, so it set no verifier".into();
st.proving.verifier_note = crate::verifier::note("unknown", "", "", true);
// another node holds the app's ports: check whose rules it runs before reading it (publish 2's read-back,
// 6 October 2026: the Mac read the hand node silently as its own); the answer comes back as Cmd::ExternalNode
let port = self.shared.runtime.rpc_port;
{
let mut st = self.st();
st.node.state = "starting".into();
st.node.source = String::new();
st.node.message = format!("another node holds port {port} on this machine; checking its rules");
}
self.shared.log(&format!("port {port} is held by another node; asking it for its chain id and rules before using it"));
let shared = self.shared.clone();
let evm_port = self.shared.runtime.evm_port();
std::thread::spawn(move || { shared.send(Cmd::ExternalNode(crate::extnode::probe(evm_port))); });
} else {
crate::extnode::prune_logs(&self.shared.runtime.log_dir, "node-", 10);
self.set_node_mode("app", "own", "the ports were free at launch, so the app started its own node");
self.start_node();
}
self.plan_miners();
@ -1422,6 +1485,74 @@ impl Engine {
/// 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.
/// The check of another node on the app's ports came back (src/extnode.rs): use it and say so, or refuse it
fn external_node_decided(&mut self, check: crate::extnode::Check) {
let port = self.shared.runtime.p2p_port;
let ours = self.node_override_file().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str::<Value>(&t).ok());
let network = self.shared.runtime.network.clone();
let d = crate::extnode::decide_on(port, ours.as_ref(), None, Some(network.as_str()), &check);
self.node_external = d.use_it;
self.node_refused = !d.use_it;
self.external_gone_since = None;
self.set_node_mode(if d.use_it { "external" } else { "none" }, if d.use_it { "external" } else { "none" }, &d.line);
{
let mut st = self.st();
st.node.rules_check = d.verdict.into();
st.node.port_note = d.line.clone();
}
if d.use_it {
self.shared.event(if d.verdict == "match" { "info" } else { "error" }, &d.line);
let mut st = self.st();
st.node.state = "syncing".into();
st.node.message = "another node on this machine".into();
if let Some(dg) = check.digest.filter(|x| x.len() >= 16) { st.node.consensus_digest = dg; st.node.digest_source = "rpc".into(); }
if let Some(v) = check.version { if !v.is_empty() { st.node.version = v; } }
st.proving.verifier_reason = "external node: the app did not start it, so it set no verifier".into();
st.proving.verifier_note = crate::verifier::note("unknown", "", "", true);
} else {
{
let mut st = self.st();
st.node.state = "stopped".into();
st.node.synced = false;
st.node.message = d.line.clone();
}
self.shared.event("error", &d.line);
self.shared.event("error", "mining waits: stop the other node or start this app with other ports, then open the app again");
}
}
/// Record which node the app reads (api/state node.source, node.mode, node.mode_reason).
fn set_node_mode(&self, source: &str, mode: &str, reason: &str) {
let mut st = self.st();
st.node.source = source.into();
st.node.mode = mode.into();
st.node.mode_reason = reason.into();
}
/// The other node left its ports for TAKEOVER_WAIT_S: the app starts its own node on them and says so.
fn take_over_ports(&mut self) {
let port = self.shared.runtime.rpc_port;
let was = if self.node_external { "the node this app was reading" } else { "the node this app refused" };
self.node_external = false;
self.node_refused = false;
self.external_gone_since = None;
let reason = format!("{was} left port {port} for {} s, so the app started its own node", crate::extnode::TAKEOVER_WAIT_S as u64);
self.shared.event("info", &format!("The other node went away; this app starts its own node on port {port}"));
self.set_node_mode("app", "own", &reason);
{
let mut st = self.st();
st.node.rules_check = String::new();
st.node.port_note = reason.clone();
st.node.synced = false;
st.node.consensus_digest = String::new();
st.node.digest_source = String::new();
st.node.version = String::new();
}
self.node_last_reading = None;
crate::extnode::prune_logs(&self.shared.runtime.log_dir, "node-", 10);
self.start_node();
}
fn node_override_file(&self) -> Option<std::path::PathBuf> {
if self.node_override_unusable {
return None;
@ -1469,6 +1600,9 @@ impl Engine {
let mut st = self.st();
st.node.pid = p.pid();
st.node.state = "starting".into();
st.node.stall_exits = 0;
st.node.tip_age_s = -1.0;
st.node.sync_cause = String::new();
st.node.starts = self.node_starts;
st.node.message = "opening the database".into();
st.node.restart_in_s = 0;
@ -1664,6 +1798,11 @@ impl Engine {
match procs::spawn(Source::Miner(card_idx), &self.bins.miner, &args, cwd.as_deref(), &log, &self.lines_tx, &envs) {
Ok(p) => {
self.shared.log(&format!("miner {} started (pid {}): {}", self.miners[i].label, p.pid(), p.cmdline));
if self.miners[i].starts == 1 {
// one Activity line per card at its first start (an OTA relaunch shows every card coming back)
let name = self.st().mining.cards.get(self.miners[i].card).map(|c| c.name.clone()).unwrap_or_else(|| self.miners[i].label.clone());
self.shared.event("info", &format!("{name}: worker starting (pid {})", p.pid()));
}
let mut st = self.st();
if let Some(c) = st.mining.cards.get_mut(card_idx) {
c.pid = p.pid();
@ -2984,6 +3123,25 @@ impl Engine {
self.last_awake = now;
crate::platform::keep_awake_tick();
}
if self.running && self.node.is_some() && !self.miners.is_empty() && now.duration_since(self.merge_view_at) >= Duration::from_secs(30) {
self.merge_view_at = now;
let shared = self.shared.clone();
let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&self.shared.packaged.live_page));
let ids: std::collections::HashSet<String> = self.st().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect();
if !live_api.is_empty() {
std::thread::spawn(move || {
if let Some((sink_blue, ours_ts_ms)) = crate::merge::view_of(&crate::live::fetch(&live_api, 600, &ids)) {
shared.send(Cmd::NetView { sink_blue, ours_ts_ms });
}
});
}
}
if self.running && (self.node_external || self.node.is_some()) && now.duration_since(self.node_info_at) >= Duration::from_secs(30) {
self.node_info_at = now;
let shared = self.shared.clone();
let evm_port = self.shared.runtime.evm_port();
std::thread::spawn(move || { let c = crate::extnode::probe(evm_port); if c.digest.is_some() || c.version.is_some() { shared.send(Cmd::NodeInfo(c)); } });
}
if self.running {
self.tick_node(now);
self.tick_watch(now);
@ -3234,12 +3392,24 @@ impl Engine {
}
fn tick_node(&mut self, now: Instant) {
if self.node_external {
if !port_open(self.shared.runtime.rpc_port) && now.duration_since(self.last_sync_check) >= Duration::from_secs(5) {
if self.node_external || self.node_refused {
// re-decided every 5 s: the other node leaving hands the ports to this app after the wait (7 October 2026)
if now.duration_since(self.last_sync_check) >= Duration::from_secs(5) {
self.last_sync_check = now;
let mut st = self.st();
st.node.state = "stopped".into();
st.node.message = "the external node went away".into();
let port = self.shared.runtime.rpc_port;
let first = self.external_gone_since.is_none();
match crate::extnode::step(port_open(port), self.secs(now), &mut self.external_gone_since) {
crate::extnode::Step::Stay => {}
crate::extnode::Step::Gone { for_s } => {
let left = (crate::extnode::TAKEOVER_WAIT_S - for_s).max(0.0) as u64;
if first && self.node_external { self.shared.event("info", &format!("The other node on port {port} went away; if it stays away, this app starts its own node in {left} s")); }
let mut st = self.st();
st.node.state = "stopped".into();
st.node.synced = false;
st.node.message = format!("the other node on port {port} went away; this app starts its own node in {left} s unless it comes back");
}
crate::extnode::Step::TakeOver => self.take_over_ports(),
}
}
return;
}
@ -3385,6 +3555,30 @@ impl Engine {
} else if verdict != crate::watchdog::Action::None {
// exit 43: the miner gave up on its worker; once more, then the card is faulted
self.watchdog_verdict(i, verdict, &tail);
} else if is_stall_exit(code, &tail) {
// N4: the miner found no new template for its stall span and stopped hashing and voting. Once:
// the miner restarts and the node gets another look. Twice since the node started: the node
// itself restarts and re-dials its peers (the seed list), because the miner alone cannot fix a
// node that fell off the network.
let n = { let mut st = self.st(); st.node.stall_exits += 1; st.node.stall_exits };
let label = self.miners[i].label.clone();
for l in &tail {
self.shared.log(&format!(" miner: {l}"));
}
if n >= 2 {
self.shared.event("error", &format!("{label}: no new block to work on, twice; the node restarts and re-dials its peers"));
self.st().node.stall_exits = 0;
self.restart_node("the miner saw no new block twice", Duration::from_secs(2));
} else {
let delay = jitter_secs(crate::platform::unix_now() + i as u64);
self.miners[i].restart_at = Some(now + Duration::from_secs(delay));
self.shared.event("error", &format!("{label}: no new block to work on for the stall span; the miner restarts in {delay} s (a second stall restarts the node)"));
if let Some(c) = self.st().mining.cards.get_mut(card_idx) {
c.state = "restarting".into();
c.hash_now = 0.0;
c.message = "no new block to work on; waiting for the node".into();
}
}
} else {
self.miners[i].restarts += 1;
let delay = jitter_secs(crate::platform::unix_now() + i as u64);
@ -3624,6 +3818,7 @@ impl Engine {
if st.node.consensus_digest != d {
st.node.consensus_digest = d;
}
if st.node.digest_source.is_empty() { st.node.digest_source = "log".into(); }
return;
}
if !text.contains("too far into the future") {
@ -3729,6 +3924,8 @@ impl Engine {
let blue = kv_u64(text, "blue").unwrap_or(0);
let difficulty = kv_f64(text, "difficulty").unwrap_or(0.0);
let flag = kv(text, "synced").map(|s| s == "true");
// N4: the sink header's age from the node (ca3-v4-0316 appends tip_age_s after synced=); -1 on an older node
let tip_age_s = kv(text, "tip_age_s").and_then(|s| s.parse::<f64>().ok()).unwrap_or(-1.0);
let was_synced = self.st().node.synced;
// Derived from every reading, never a one-shot transition (PC 2, 4 October 2026: the node caught up after a
// clock fix and mined at 118 MH/s while the card still said "syncing", because getInfo's flag stayed false).
@ -3741,7 +3938,24 @@ impl Engine {
let stable_s = self.sync_stable_since.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(0.0);
let accepted_recent = self.last_accepted.map(|t| now.duration_since(t) <= Duration::from_secs(60)).unwrap_or(false);
let private = self.shared.runtime.unsynced_mining && self.shared.runtime.peers.is_empty();
let synced = sync_decision(&Reading { blocks, headers, peers, flag }, caught_up_moving, stable_s, accepted_recent, private);
let (mut synced, mut cause) = sync_decision_v2(&Reading { blocks, headers, peers, flag }, caught_up_moving, stable_s, accepted_recent, private, tip_age_s);
// 7 October 2026: a synced-looking node whose blocks never merge into the network reads "behind" and holds the miner
if synced && !private {
let since = *self.merge_mining_since.get_or_insert(now);
let check = crate::merge::MergeCheck {
our_blue: blue,
peer_sinks: self.merge_sinks.clone(),
merge_depth: self.merge_depth,
ours_seen_ago_s: self.merge_ours_seen.map(|t| now.duration_since(t).as_secs_f64()),
mining_for_s: now.duration_since(since).as_secs_f64(),
};
if crate::merge::not_merging(&check) {
synced = false;
cause = "not merging";
}
} else if !synced {
self.merge_mining_since = None;
}
self.sync_prev = Some(blocks);
// the first clock source: local time against the latest block the peers produced, once blocks arrive
if blocks > 0 && (peers > 0 || self.shared.runtime.peers.is_empty()) && now.duration_since(self.clock_last_sample) >= Duration::from_secs(9) {
@ -3764,20 +3978,39 @@ impl Engine {
st.node.blue = blue;
st.node.difficulty = difficulty;
st.node.synced = synced;
st.node.state = if synced { "synced".into() } else { "syncing".into() };
st.node.tip_age_s = tip_age_s;
st.node.sync_cause = cause.to_string();
// the state word: "behind" for a frozen tip, "no peers" for none, "syncing" for the rest (N4: never
// "synced" on a tip that stopped moving)
st.node.state = if synced { "synced".into() } else if cause == "frozen" || cause == "not merging" { "behind".into() } else if cause == "no peers" { "no peers".into() } else { "syncing".into() };
st.node.message = if synced {
String::new()
} else if cause == "not merging" {
crate::merge::NOT_MERGING.to_string()
} else if cause == "frozen" {
format!("no new block for {} s with {peers} peer{}; the chain may have moved on without this node", tip_age_s.round() as i64, if peers == 1 { "" } else { "s" })
} else if peers == 0 {
"waiting for a peer".into()
"waiting for a peer on our chain".into()
} else if headers > blocks + 2 {
format!("{blocks} of {headers} blocks")
} else {
"caught up, waiting for the next block".into()
};
}
if !synced && was_synced && cause == "not merging" {
self.shared.event("error", &format!("{}; the network's sink is {} blocks past ours, so the miner is held until this node merges again", crate::merge::NOT_MERGING, self.merge_sinks.iter().max().copied().unwrap_or(0).saturating_sub(blue)));
}
if synced && !was_synced {
self.shared.event("ok", &format!("node synced: {blocks} blocks, {peers} peer(s)"));
let t = self.secs(now);
let names: Vec<String> = self.st().mining.cards.iter().map(|c| c.name.clone()).collect();
for m in self.miners.iter_mut() {
// a card faulted for silence while the node was not ready starts again now (PC 1, 7 October 2026)
if m.watch.faulted_by_node() {
m.watch.event(t, crate::watchdog::Event::NodeSynced);
let name = names.get(m.card).cloned().unwrap_or_else(|| m.label.clone());
self.shared.event("info", &format!("{name}: starts again now the node is synced (it was faulted for silence while the node was syncing)"));
}
if m.proc.is_none() && m.restart_at.is_none() && m.watch.faulted().is_none() {
m.restart_at = Some(now);
}
@ -3793,6 +4026,22 @@ impl Engine {
let Some(i) = self.miners.iter().position(|m| m.card == card) else { return };
let label = self.miners[i].label.clone();
let card_name = self.st().mining.cards.get(card).map(|c| c.name.clone()).unwrap_or_else(|| label.clone());
if text.contains("template fetch timed out") {
// PC 1, 7 October 2026 04:51Z: the node reported synced while its finality replay blocked the template
// RPC for three minutes; the miner printed only these lines, and the watchdog faulted every card for
// silence. The line is the miner's heartbeat: the card waits on the node, with one Activity line per episode
let now = Instant::now();
let t = self.secs(now);
let first = !self.miners[i].watch.templates_blocked();
self.miners[i].watch.event(t, crate::watchdog::Event::TemplateTimeout);
if first {
self.shared.event("info", &format!("{card_name}: the node is not answering block templates yet; the miner keeps asking (the node catches up after a restart)"));
}
if let Some(c) = self.st().mining.cards.get_mut(card) {
c.message = "waiting for the node to answer block templates".into();
}
return;
}
if stderr {
if text.contains(" rejected nonce=") {
if let Some(run) = self.sweep.as_mut() {
@ -4306,6 +4555,38 @@ pub fn sync_decision(r: &Reading, moving: bool, stable_s: f64, accepted_recent:
caught_up && (moving || stable_s >= 60.0)
}
/// The tip age at which a node that still says "synced" is treated as frozen (ledger N4, 6 October 2026: a home
/// miner's one same-digest peer relayed nothing for 53 minutes while the app read "mining, synced").
pub const TIP_FROZEN_S: f64 = 120.0;
/// N4: what "synced" means on every surface. The old decision, AND the tip moved in the last 120 s (the watch
/// line's tip_age_s, the sink header's age; -1 = the node does not report it yet, which gates nothing so an older node
/// keeps today's words), AND at least one peer (the handshake refuses a peer with another digest, so `peers` already
/// counts peers on our chain). Returns (synced, cause): the cause is "" when synced, else "frozen" (a peer, caught up,
/// no new block for over 120 s), "no peers", "syncing", or "behind" (the node's own flag false while the count moves).
pub fn sync_decision_v2(r: &Reading, moving: bool, stable_s: f64, accepted_recent: bool, private: bool, tip_age_s: f64) -> (bool, &'static str) {
if private {
return (true, "");
}
let frozen = tip_age_s > TIP_FROZEN_S && !accepted_recent;
if r.peers == 0 && !accepted_recent {
return (false, "no peers");
}
if frozen {
return (false, "frozen");
}
if sync_decision(r, moving, stable_s, accepted_recent, private) {
return (true, "");
}
(false, if r.headers > r.blocks + 2 { "syncing" } else { "behind" })
}
/// The miner's stall exit (the node lane, ca3-v4-0316): code 45, or a tail line starting "STALLED '".
pub const STALL_EXIT_CODE: i32 = 45;
pub fn is_stall_exit(code: i32, tail: &[String]) -> bool {
code == STALL_EXIT_CODE || tail.iter().any(|l| l.contains(" STALLED '") || l.starts_with("STALLED '"))
}
/// "...block timestamp is 1791112663000 but maximum timestamp allowed is 1791112600000" -> at least 63 s behind.
fn behind_from_warning(text: &str) -> Option<f64> {
let nums: Vec<f64> = text.split(|c: char| !c.is_ascii_digit()).filter(|s| s.len() >= 12).filter_map(|s| s.parse::<f64>().ok()).collect();
@ -4464,7 +4745,7 @@ mod tests {
assert_eq!(super::power_control_after_prompt(&Err("the elevated step exited with code 2".into())), None);
}
use super::{digest_from_line, parse_race, switches_of, sync_decision, Reading};
use super::{is_stall_exit, sync_decision_v2, digest_from_line, parse_race, switches_of, sync_decision, Reading};
#[test]
fn digest_comes_from_the_nodes_own_line_only() {
@ -4536,6 +4817,44 @@ mod tests {
assert!(sync_decision(&r(0, 0, 0, None), false, 0.0, false, true));
}
/// Ledger N4 (6 October 2026): a home miner's node kept one same-digest peer that relayed nothing for 53 minutes;
/// today's rule said "synced" the whole time. The known-failed case first, then the fix.
#[test]
fn n4_a_frozen_tip_is_never_synced() {
// the frozen tip: caught up, one peer, the count stable for an hour, getInfo's flag true. Today: synced.
let frozen = r(133_000, 133_000, 1, Some(true));
assert!(sync_decision(&frozen, false, 3_180.0, false, false), "the old rule says synced on the frozen tip (the N4 failure)");
// the fix: the tip age gates it
let (ok, cause) = sync_decision_v2(&frozen, false, 3_180.0, false, false, 3_180.0);
assert!(!ok);
assert_eq!(cause, "frozen");
// the same node moving: synced
assert_eq!(sync_decision_v2(&frozen, true, 0.0, false, false, 4.0), (true, ""));
// exactly at the bound is still synced; one second over is not
assert_eq!(sync_decision_v2(&frozen, false, 60.0, false, false, 120.0), (true, ""));
assert_eq!(sync_decision_v2(&frozen, false, 60.0, false, false, 121.0), (false, "frozen"));
// an older node that does not report the age (-1) keeps today's words
assert_eq!(sync_decision_v2(&frozen, false, 60.0, false, false, -1.0), (true, ""));
// our own block accepted in the last minute proves the tip moves, whatever the reported age
assert_eq!(sync_decision_v2(&frozen, false, 60.0, true, false, 900.0), (true, ""));
// no peer at all: "no peers", not "frozen" and never synced
assert_eq!(sync_decision_v2(&r(133_000, 133_000, 0, Some(true)), false, 600.0, false, false, 500.0), (false, "no peers"));
// still downloading: "syncing"
assert_eq!(sync_decision_v2(&r(100, 700, 1, Some(false)), true, 0.0, false, false, 2.0), (false, "syncing"));
// caught up but the flag false and the count not yet stable: "behind"
assert_eq!(sync_decision_v2(&r(700, 700, 1, Some(false)), false, 10.0, false, false, 5.0), (false, "behind"));
// a private test node is always synced
assert_eq!(sync_decision_v2(&r(0, 0, 0, None), false, 0.0, false, true, 9_999.0), (true, ""));
}
#[test]
fn n4_the_stall_exit_is_code_45_or_the_stalled_line() {
assert!(is_stall_exit(45, &[]));
assert!(is_stall_exit(1, &["1791327000.123 STALLED 'nvidia-1ccfe586-1': no new template for 300 s (tip d3dc2f78, daa 197752); the node has fallen off the network or stopped moving; hashing and voting paused, exiting with code 45 so the launcher restarts (and after a second stall, restarts the node)".to_string()]));
assert!(!is_stall_exit(1, &["worker fault: rejected nonce".to_string()]));
assert!(!is_stall_exit(42, &[]));
}
#[test]
fn node_warning() {
let l = "2026-10-04 11:02:11.123+00:00 [WARN ] HandleRelayInvsFlow flow error: the block timestamp is too far into the future: block timestamp is 1791112663000 but maximum timestamp allowed is 1791112600000";

View file

@ -0,0 +1,249 @@
//! Another node on this machine (publish 2's read-back, 6 October 2026): the hand node on the Mac held 26610/26611,
//! the app's default RPC and p2p ports, the app read its state as its own and the digest field read empty. Now the
//! collision is loud on every surface and checked: the other node's chain id (eth_chainId on the EVM port) and its
//! consensus overrides (`igneum_getNodeInfo`, the node lane, 7 October 2026; "method not found" on an older node) against the
//! override file the app would start its own node with. A match: "Another node holds port N on this machine; using
//! it". A mismatch: "Node not started: port N is taken" and the app does not read that node. Unknown (an older node
//! that cannot answer): used, and said so. The same RPC gives the digest, so api/state carries it for an external node
//! too, read every 30 s instead of parsed from a stdout the app does not own.
use serde_json::{json, Value};
use std::path::Path;
use std::process::Command;
use std::time::Duration;
/// What the other node answered: None where it could not answer.
#[derive(Debug, Default, Clone)]
pub struct Check {
pub chain_id: Option<u64>,
pub digest: Option<String>,
pub network: Option<String>,
pub version: Option<String>,
/// the node's consensus params as it resolved them (`params` in igneum_getNodeInfo, every field by its override-file name)
pub overrides: Option<Value>,
/// "igneum-pow" or "stub" (ledger N5: a node built without the mining engine refuses every real block)
pub pow_engine: Option<String>,
/// the network's compiled merge depth in blue score (`blockrate.mergeDepth`, the node lane's addition of 7 October 2026);
/// None on a node without the object, and the app assumes 3,600 (1 bps)
pub merge_depth: Option<u64>,
}
/// The decision for the port-collision path.
#[derive(Debug, PartialEq)]
pub struct Decision {
pub use_it: bool,
/// match | mismatch | unknown
pub verdict: &'static str,
/// the event line, in a user's words
pub line: String,
}
/// Compare the other node with ours: `ours` is the override file the app would start its own node with (the
/// `*_activation_daa` and friends), `our_chain` the chain id this app's network uses when known.
pub fn decide(port: u16, ours: Option<&Value>, our_chain: Option<u64>, theirs: &Check) -> Decision { decide_on(port, ours, our_chain, None, theirs) }
/// `decide` with the network name too (devnet | simnet | testnet): the node lane's rule is that `network` must be equal.
pub fn decide_on(port: u16, ours: Option<&Value>, our_chain: Option<u64>, our_network: Option<&str>, theirs: &Check) -> Decision {
let mut checked = false;
if let (Some(a), Some(b)) = (our_network.filter(|s| !s.is_empty()), theirs.network.as_deref().filter(|s| !s.is_empty())) {
checked = true;
if a != b {
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on another network ({b}, ours {a})") };
}
}
if let (Some(a), Some(b)) = (our_chain, theirs.chain_id) {
checked = true;
if a != b {
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on another network (chain id {b}, ours {a})") };
}
}
if theirs.pow_engine.as_deref() == Some("stub") {
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node built without the mining engine (stub), which refuses every real block") };
}
// every key the manifest sets, against the node's resolved params (the node lane: compare values, never recompute the hash)
if let (Some(o), Some(t)) = (ours.and_then(|v| v.as_object()), theirs.overrides.as_ref().and_then(|v| v.as_object())) {
checked = true;
for (k, v) in o {
if t.get(k) != Some(v) {
let theirs_v = t.get(k).map(|x| x.to_string()).unwrap_or_else(|| "none".into());
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on other rules ({k} {theirs_v}, ours {v})") };
}
}
}
if checked {
Decision { use_it: true, verdict: "match", line: format!("Another node holds port {port} on this machine; using it (same network and rules; it is not stopped by this app)") }
} else {
Decision { use_it: true, verdict: "unknown", line: format!("Another node holds port {port} on this machine; using it. Its rules could not be checked (an older node that lacks igneum_getNodeInfo), so the app reads it as it is") }
}
}
/// How long the app waits after another node leaves its ports before starting its own (the Mac, 7 October 2026: the
/// hand node left at 00:18 UK and the app sat on "node stopped" all night, since the mode was decided once at launch).
pub const TAKEOVER_WAIT_S: f64 = 60.0;
/// What the app does about ports another node held, re-checked while attached (external) or refused (none).
#[derive(Debug, PartialEq)]
pub enum Step {
/// the other node still answers on the port
Stay,
/// the port has been closed for `for_s` seconds; the app waits out TAKEOVER_WAIT_S in case it comes back
Gone { for_s: f64 },
/// the port stayed closed for TAKEOVER_WAIT_S: start our own node on the freed ports
TakeOver,
}
/// `port_open` is whether the other node's RPC port answers now, `gone_since` the app's memory of when it stopped
/// answering (None while it answers). A node that comes back inside the wait is kept; the wait starts over.
pub fn step(port_open: bool, now_s: f64, gone_since: &mut Option<f64>) -> Step {
if port_open {
*gone_since = None;
return Step::Stay;
}
let since = *gone_since.get_or_insert(now_s);
let for_s = now_s - since;
if for_s >= TAKEOVER_WAIT_S {
*gone_since = None;
Step::TakeOver
} else {
Step::Gone { for_s }
}
}
/// One JSON-RPC call to the node's EVM port through curl (the engine carries no HTTP client; update.rs does the same).
pub fn rpc(evm_port: u16, method: &str, params: Value, limit: Duration) -> Option<Value> {
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
let out = crate::detect::run_timeout(
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", &format!("{}", limit.as_secs().max(1)), "-X", "POST", &format!("http://127.0.0.1:{evm_port}"), "-H", "Content-Type: application/json", "-d", &body]),
None,
limit + Duration::from_secs(2),
)?;
let v: Value = serde_json::from_str(&out).ok()?;
v.get("result").cloned().filter(|r| !r.is_null())
}
/// The other node's answers, with what each method gives: eth_chainId (every node), igneum_getNodeInfo (newer nodes).
pub fn probe(evm_port: u16) -> Check {
let mut c = Check::default();
if let Some(Value::String(h)) = rpc(evm_port, "eth_chainId", json!([]), Duration::from_secs(4)) {
c.chain_id = u64::from_str_radix(h.trim_start_matches("0x"), 16).ok();
}
if let Some(info) = rpc(evm_port, "igneum_getNodeInfo", json!([]), Duration::from_secs(4)) {
c.digest = info.get("digest").and_then(|v| v.as_str()).map(|s| s.to_string());
c.network = info.get("network").and_then(|v| v.as_str()).map(|s| s.to_string());
c.version = info.get("version").and_then(|v| v.as_str()).map(|s| s.to_string());
c.overrides = info.get("params").cloned().filter(|v| v.is_object());
c.pow_engine = info.get("powEngine").and_then(|v| v.as_str()).map(|s| s.to_string());
c.merge_depth = info.get("blockrate").and_then(|b| b.get("mergeDepth")).and_then(|v| v.as_u64());
}
c
}
/// Keep the newest `keep` files named `<prefix>...` in `dir`; the rest go (the node and miner logs grow one per start).
pub fn prune_logs(dir: &Path, prefix: &str, keep: usize) -> usize {
let Ok(rd) = std::fs::read_dir(dir) else { return 0 };
let mut files: Vec<(std::time::SystemTime, std::path::PathBuf)> = rd
.flatten()
.filter(|e| e.file_name().to_string_lossy().starts_with(prefix) && e.path().extension().map(|x| x == "log").unwrap_or(false))
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()).map(|t| (t, e.path())))
.collect();
files.sort_by(|a, b| b.0.cmp(&a.0));
let mut removed = 0;
for (_, p) in files.into_iter().skip(keep) {
if std::fs::remove_file(&p).is_ok() {
removed += 1;
}
}
removed
}
#[cfg(test)]
mod tests {
use super::*;
fn ours() -> Value { json!({ "difficulty_v2_activation_daa": 33000, "fees_v1_activation_daa": 210000, "exec_restart_number": 27276, "exec_restart_hash": "bb45cf0d" }) }
/// The Mac, 6 October 2026: the hand node held the ports; the app said nothing and read it as its own.
#[test]
fn a_node_on_our_rules_is_used_and_said_so() {
let theirs = Check { chain_id: Some(7_777), overrides: Some(ours()), digest: Some("1f4b".into()), ..Default::default() };
let d = decide(26611, Some(&ours()), Some(7_777), &theirs);
assert!(d.use_it);
assert_eq!(d.verdict, "match");
assert_eq!(d.line, "Another node holds port 26611 on this machine; using it (same network and rules; it is not stopped by this app)");
}
#[test]
fn a_node_on_another_network_or_other_rules_is_refused() {
let other_chain = Check { chain_id: Some(7_778), ..Default::default() };
let d = decide(26611, Some(&ours()), Some(7_777), &other_chain);
assert!(!d.use_it);
assert_eq!(d.verdict, "mismatch");
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)");
let mut theirs = ours(); theirs["fees_v1_activation_daa"] = json!(200000);
let d = decide(26611, Some(&ours()), None, &Check { overrides: Some(theirs), ..Default::default() });
assert!(!d.use_it);
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on other rules (fees_v1_activation_daa 200000, ours 210000)");
// the merge depth comes from blockrate.mergeDepth (compiled per network), never from params
assert_eq!(Check { merge_depth: Some(36_000), ..Default::default() }.merge_depth, Some(36_000));
// a stub engine is refused whatever else matches
assert!(!decide(26611, Some(&ours()), None, &Check { overrides: Some(ours()), pow_engine: Some("stub".into()), ..Default::default() }).use_it);
// an override we have that the other node lacks is a mismatch too
let d = decide(26611, Some(&ours()), None, &Check { overrides: Some(json!({ "difficulty_v2_activation_daa": 33000 })), ..Default::default() });
assert!(!d.use_it);
assert!(d.line.contains("exec_restart_hash none"), "{}", d.line);
// the network name (node lane, igneum_getNodeInfo.network) must be equal
let d = decide_on(26611, Some(&ours()), None, Some("testnet"), &Check { network: Some("devnet".into()), overrides: Some(ours()), ..Default::default() });
assert!(!d.use_it);
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on another network (devnet, ours testnet)");
assert!(decide_on(26611, Some(&ours()), None, Some("testnet"), &Check { network: Some("testnet".into()), overrides: Some(ours()), ..Default::default() }).use_it);
}
#[test]
fn an_older_node_that_cannot_answer_is_used_and_marked_unknown() {
let d = decide(26611, Some(&ours()), None, &Check::default());
assert!(d.use_it);
assert_eq!(d.verdict, "unknown");
assert!(d.line.starts_with("Another node holds port 26611 on this machine; using it. Its rules could not be checked"));
// a chain id alone, matching, is a check
assert_eq!(decide(26611, None, Some(1), &Check { chain_id: Some(1), ..Default::default() }).verdict, "match");
}
/// The Mac, 7 October 2026 00:18 UK: the hand node left and the app never started its own (today's app never recovers).
#[test]
fn an_external_node_that_goes_away_hands_the_ports_to_the_app_after_the_wait() {
let mut gone = None;
assert_eq!(step(true, 0.0, &mut gone), Step::Stay);
assert_eq!(gone, None);
assert_eq!(step(false, 100.0, &mut gone), Step::Gone { for_s: 0.0 });
assert_eq!(step(false, 130.0, &mut gone), Step::Gone { for_s: 30.0 });
assert_eq!(step(false, 160.0, &mut gone), Step::TakeOver);
assert_eq!(gone, None, "the memory resets once the app has its own node");
// a node that comes back inside the wait is kept, and the wait starts over
let mut gone = None;
assert_eq!(step(false, 0.0, &mut gone), Step::Gone { for_s: 0.0 });
assert_eq!(step(true, 30.0, &mut gone), Step::Stay);
assert_eq!(step(false, 40.0, &mut gone), Step::Gone { for_s: 0.0 });
assert_eq!(step(false, 99.0, &mut gone), Step::Gone { for_s: 59.0 });
assert_eq!(step(false, 100.0, &mut gone), Step::TakeOver);
}
#[test]
fn prune_keeps_the_newest_logs() {
let dir = std::env::temp_dir().join(format!("igneum-prune-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for i in 0..6 {
let p = dir.join(format!("node-2026100{i}.log"));
std::fs::write(&p, "x").unwrap();
let t = std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000 + i * 60);
let f = std::fs::File::options().write(true).open(&p).unwrap();
f.set_modified(t).unwrap();
}
std::fs::write(dir.join("miner-1.log"), "y").unwrap();
std::fs::write(dir.join("node-notes.txt"), "z").unwrap();
assert_eq!(prune_logs(&dir, "node-", 4), 2);
let mut left: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
left.sort();
assert_eq!(left, vec!["miner-1.log", "node-20261002.log", "node-20261003.log", "node-20261004.log", "node-20261005.log", "node-notes.txt"]);
let _ = std::fs::remove_dir_all(&dir);
}
}

View file

@ -16,8 +16,8 @@ static CACHE: Mutex<Option<(Instant, u32, Value)>> = Mutex::new(None);
const TTL: Duration = Duration::from_secs(2);
/// The observer's reply with this machine's lane marked. `ids` are the 8-character vote key ids of this machine's
/// cards. Blocks whose `miner` is one of them read `miner: "you"`; `state.you_blocks` counts them; `state.source` names
/// where the data came from. Anything else passes through untouched, so the module's contract holds.
/// cards. Blocks whose `miner` is one of them read `miner: "you"`; `state.you_blocks` counts them; `state.source` is "site"
/// (the 0.3.16 engine's live.rs answers "node" when igneumd carries igneum_getRecentBlocks). Anything else passes through untouched, so the module's contract holds.
pub fn shape(mut reply: Value, ids: &HashSet<String>) -> Value {
let mut yours = 0u64;
if let Some(blocks) = reply.get_mut("blocks").and_then(|b| b.as_array_mut()) {
@ -31,7 +31,7 @@ pub fn shape(mut reply: Value, ids: &HashSet<String>) -> Value {
}
if let Some(st) = reply.get_mut("state").and_then(|s| s.as_object_mut()) {
st.insert("you_blocks".into(), json!(yours));
st.insert("source".into(), json!("observer"));
st.insert("source".into(), json!("site"));
}
reply
}
@ -71,6 +71,7 @@ pub fn fetch(live_api: &str, window_s: u32, ids: &HashSet<String>) -> Value {
mod tests {
use super::*;
// a fixture in the observer's shape (site/api/live.mjs): three miners, this machine is 8fafda27, one of its blocks on
// the selected chain and proven, one excluded; a locked and a pending checkpoint
fn fixture() -> Value {
@ -109,7 +110,7 @@ mod tests {
assert_eq!(out["finality"]["checkpoints"].as_array().unwrap().len(), 2);
assert_eq!(out["finality"]["checkpoints"][0]["state"], "locked");
assert_eq!(out["state"]["you_blocks"], 2);
assert_eq!(out["state"]["source"], "observer");
assert_eq!(out["state"]["source"], "site");
assert_eq!(out["state"]["height"], 198490);
assert_eq!(out["ok"], true);
}

View file

@ -38,6 +38,8 @@ mod ember;
mod powertask;
mod watchdog;
mod live;
mod extnode;
mod merge;
use std::io::{BufRead, Write};
use std::sync::mpsc::channel;

View file

@ -0,0 +1,96 @@
//! Is this node's work merging into the network? (design note, 7 October 2026, from the node lane's 30-s
//! propagation reading: a node behind a slow link keeps mining its own chain, its relayed blocks never merge, and the
//! N4 "synced" test (tip moving, a peer present) cannot see it.)
//!
//! The decision is pure. The engine feeds it our sink's blue score (the watch line's `blue=`), the sinks the network
//! reports (the observer's view, and any peer that answers igneum_getNodeInfo), the merge depth from the node's params,
//! how long ago one of our blocks was last seen in the network's view, and how long we have been mining.
/// How long our blocks may stay out of the network's view before the node reads "behind" (two minutes).
pub const NOT_MERGING_S: f64 = 120.0;
/// The merge depth when the node does not say (node lane, 7 October 2026: 1 bps x MERGE_DEPTH_DURATION = 3,600 in blue
/// score on the devnet and every 1-bps network; 36,000 at 10 bps; read from igneum_getNodeInfo's `blockrate.mergeDepth`
/// once the node carries it). Peer sinks: nothing exposes them yet; `igneum_getPeers` with lastDeliveredBlueScore is on
/// the 0.3.19 list, and the observer is the only network view until then.
pub const DEFAULT_MERGE_DEPTH: u64 = 3_600;
/// The words every surface uses for this state.
pub const NOT_MERGING: &str = "behind: your blocks are not merging into the network";
#[derive(Debug, Clone, Default)]
pub struct MergeCheck {
/// our node's sink blue score
pub our_blue: u64,
/// the sinks' blue scores the network reports (observer, peers); empty = no view, no decision
pub peer_sinks: Vec<u64>,
/// the merge depth in blue score (the node's params, else DEFAULT_MERGE_DEPTH)
pub merge_depth: u64,
/// seconds since one of our blocks last appeared in the network's view; None = never seen
pub ours_seen_ago_s: Option<f64>,
/// how long this machine has been mining with a synced node, in seconds
pub mining_for_s: f64,
}
/// True when every sink the network reports is more than the merge depth ahead of ours and none of our blocks has
/// appeared in the network's view for NOT_MERGING_S (so the miner's blocks can no longer merge: hold it).
pub fn not_merging(c: &MergeCheck) -> bool {
if c.peer_sinks.is_empty() || c.mining_for_s < NOT_MERGING_S {
return false;
}
let depth = if c.merge_depth == 0 { DEFAULT_MERGE_DEPTH } else { c.merge_depth };
let all_ahead = c.peer_sinks.iter().all(|&s| s > c.our_blue.saturating_add(depth));
let ours_absent = c.ours_seen_ago_s.map(|a| a >= NOT_MERGING_S).unwrap_or(true);
all_ahead && ours_absent
}
/// The network's view out of an observer reply (crate::live::fetch's shape): the highest blue score it shows, and the
/// newest timestamp (unix ms) of a block it marks as ours (miner "you"). None when the reply carries no blocks.
pub fn view_of(reply: &serde_json::Value) -> Option<(u64, Option<i64>)> {
let blocks = reply.get("blocks")?.as_array()?;
if blocks.is_empty() {
return None;
}
let sink = blocks.iter().filter_map(|b| b.get("blue_score").and_then(|v| v.as_u64())).max().unwrap_or(0);
let ours = blocks.iter().filter(|b| b.get("miner").and_then(|m| m.as_str()) == Some("you")).filter_map(|b| b.get("ts").and_then(|v| v.as_i64())).max();
Some((sink, ours))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn c() -> MergeCheck { MergeCheck { our_blue: 100_000, peer_sinks: vec![104_000, 103_800], merge_depth: 3_600, ours_seen_ago_s: None, mining_for_s: 600.0 } }
/// The slow-link node (node lane, 30-s propagation reading): tip moving, a peer present, its own chain, nothing
/// merging. Today's sync test calls it synced; this one holds the miner.
#[test]
fn a_node_whose_blocks_never_merge_reads_behind_and_holds_the_miner() {
assert!(not_merging(&c()), "every peer more than the merge depth ahead and ours never seen");
assert!(not_merging(&MergeCheck { ours_seen_ago_s: Some(130.0), ..c() }), "ours last seen over two minutes ago");
assert_eq!(NOT_MERGING, "behind: your blocks are not merging into the network");
}
#[test]
fn a_merging_node_is_left_alone() {
assert!(!not_merging(&MergeCheck { ours_seen_ago_s: Some(20.0), ..c() }), "our block appeared in the network's view");
assert!(!not_merging(&MergeCheck { peer_sinks: vec![104_000, 101_000], ..c() }), "one peer within the merge depth");
assert!(!not_merging(&MergeCheck { peer_sinks: vec![103_600], ..c() }), "exactly the merge depth is not over it");
assert!(!not_merging(&MergeCheck { peer_sinks: vec![], ..c() }), "no network view, no decision");
assert!(!not_merging(&MergeCheck { mining_for_s: 30.0, ..c() }), "not mining long enough to judge");
assert!(not_merging(&MergeCheck { merge_depth: 0, peer_sinks: vec![103_601], ..c() }), "a missing depth falls back to 3,600");
}
#[test]
fn the_observer_reply_gives_the_sink_and_our_newest_block() {
let r = json!({ "blocks": [
{ "blue_score": 194_690, "ts": 1_791_301_600_000i64, "miner": "8fafda27" },
{ "blue_score": 194_692, "ts": 1_791_301_602_000i64, "miner": "you" },
{ "blue_score": null, "ts": 1_791_301_601_500i64, "miner": "you" },
{ "blue_score": 194_691, "ts": 1_791_301_601_000i64, "miner": "deadbeef" } ] });
assert_eq!(view_of(&r), Some((194_692, Some(1_791_301_602_000))));
let none_ours = json!({ "blocks": [{ "blue_score": 5, "ts": 1, "miner": "x" }] });
assert_eq!(view_of(&none_ours), Some((5, None)));
assert_eq!(view_of(&json!({ "blocks": [] })), None);
assert_eq!(view_of(&json!({ "ok": false })), None);
}
}

View file

@ -139,6 +139,15 @@ fn json_resp(stream: &mut TcpStream, status: u16, v: Value) {
respond(stream, status, "application/json; charset=utf-8", v.to_string().as_bytes(), false);
}
/// Where a screenshot lands: `<data root>/shots/igneum-<name>-<unix>.png`, the name reduced to [a-z0-9-] (at most
/// 24 characters, "shot" when empty) so a caller cannot steer the host outside the folder.
pub fn shot_path(data_root: &std::path::Path, name: &str, unix: u64) -> std::path::PathBuf {
let safe: String = name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').map(|c| c.to_ascii_lowercase()).take(24).collect();
let safe = if safe.is_empty() { "shot".to_string() } else { safe };
data_root.join("shots").join(format!("igneum-{safe}-{unix}.png"))
}
fn query_param(q: &str, key: &str) -> Option<String> {
q.split('&').find_map(|kv| {
let (k, v) = kv.split_once('=')?;
@ -368,6 +377,17 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
shared.send(Cmd::ClockCheck);
Ok(json!({ "ok": true }))
}
// Miner UI 4 (b): a screenshot from the machine itself. The host (app/mac, app/windows) owns the window, so the
// engine asks it over the host protocol ("SHOT <path>", beside URL, STATE and EXIT) and the host writes a PNG of
// what it shows to that path under <data root>/shots/, where a collect job's glob "shots/*.png" picks it up.
// Read-only: nothing about mining changes. A caller without a host (a bare engine) gets the path and no file.
"/api/shot" => {
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("");
let path = shot_path(&crate::platform::data_root(), name, crate::platform::unix_now());
if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); }
println!("SHOT {}", path.display());
Ok(json!({ "ok": true, "path": path.display().to_string(), "note": "the window host writes the PNG within a few seconds; a bare engine without a host writes nothing" }))
}
"/api/quit" => {
// the caller is on 127.0.0.1 and holds the token: the installer, the OTA apply, a script that read app.url
shared.send(Cmd::Quit("POST /api/quit (a local caller with the token: the installer, the OTA apply, or a script that read app.url)"));
@ -376,3 +396,17 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
_ => Err("unknown api".into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_shot_lands_under_the_data_root_with_a_safe_name() {
let root = std::path::Path::new("/tmp/igneum-data");
assert_eq!(shot_path(root, "overview", 1791327000), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-overview-1791327000.png"));
assert_eq!(shot_path(root, "../../etc/passwd", 1), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-etcpasswd-1.png"));
assert_eq!(shot_path(root, "", 2), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-shot-2.png"));
assert_eq!(shot_path(root, "Cards Light!", 3), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-cardslight-3.png"));
assert!(shot_path(root, &"x".repeat(80), 4).file_name().unwrap().to_string_lossy().len() < 48);
}
}

View file

@ -21,6 +21,29 @@ pub struct NodeState {
pub version: String,
pub last_reading_age_s: f64,
pub message: String,
/// N4 (6 October 2026, the frozen tip): seconds since the sink block's header time, from the watch line's
/// tip_age_s (the node lane, ca3-v4-0316); -1 until the node reports it. "synced" needs it at or under 120.
pub tip_age_s: f64,
/// whose node this is (publish 2's read-back, 6 October 2026): "app" (started by this app), "external" (another
/// node on this machine holds the ports; used after a check), "none" (the ports are taken by a node on other rules
/// or another network, so no node is read), "" before the first start
pub source: String,
/// for an external node: match | mismatch | unknown (an older node that cannot answer the check)
pub rules_check: String,
/// the one line that says what happened at the ports, kept for the Node details
pub port_note: String,
/// where consensus_digest came from: "rpc" (igneum_getNodeInfo), "log" (the node's own stdout line), ""
pub digest_source: String,
/// which node this app reads: "own" (it started one), "external" (another node on this machine holds the ports),
/// "none" (the ports are taken by a node it refuses), "" before the first decision. Re-decided when the other
/// node goes away (7 October 2026): after 60 s the app starts its own on the freed ports.
pub mode: String,
/// why the mode is what it is, one line
pub mode_reason: String,
/// why the node is not "synced", in plain words ("" when synced): "behind" | "no peers" | "syncing" | "frozen"
pub sync_cause: String,
/// the miner's stall exits (code 45, "STALLED") since the node last started; the second one restarts the node
pub stall_exits: u32,
/// a consensus switch the signed manifest announced (difficulty v2 activation DAA); 0 = none
pub consensus_switch_daa: u64,
pub override_restart_wait: String,

View file

@ -17,6 +17,11 @@
/// No status line from a running miner for this long: restart it.
pub const NO_STATUS_S: f64 = 90.0;
/// A worker that gives no status within this many seconds of its start, with the node synced, is one restart then
/// faulted (PC 1, 7 October 2026 04:51Z: the row asks for a fault line at 60 s).
pub const START_S: f64 = 60.0;
/// The start of a fault reason the node's readiness, not the card, explains: released when the node syncs.
const NODE_FAULTS: [&str; 2] = ["no status line from the miner", "the worker gave no status within"];
/// Hash rate 0 while the node is synced and the worker is ready for this long: restart the miner.
pub const ZERO_RATE_S: f64 = 60.0;
/// The miner is restarting its own worker: the app waits this long for `ready` before it steps in.
@ -86,6 +91,14 @@ pub enum Event<'a> {
Stopped,
/// The user changed the card's settings: a faulted card may try again.
Reset,
/// The node became synced: a card faulted for silence while the node was not ready tries again (PC 1, 7 October
/// 2026 04:51Z: every card faulted "no status line" during the post-relaunch sync and never came back).
NodeSynced,
/// The miner printed "template fetch timed out": it is alive and the node is not answering templates (PC 1,
/// 7 October 2026 04:51Z: the node reported synced from the first second while its finality replay blocked the
/// template RPC for three minutes; with no template the miner prints no status line). Silence is not the card's
/// fault while this goes on; the miner is judged again once templates flow.
TemplateTimeout,
}
#[derive(Debug, Clone, PartialEq)]
@ -109,6 +122,15 @@ pub struct CardWatch {
restarts: u32,
faulted: Option<String>,
last_fault: String,
/// the miner's last line was a template timeout (the node not answering), cleared by the next status line
templates_blocked: bool,
}
impl CardWatch {
/// True while the miner's last word was a template timeout: the node, not the card, holds the hashing.
pub fn templates_blocked(&self) -> bool {
self.templates_blocked
}
}
impl CardWatch {
@ -162,6 +184,7 @@ impl CardWatch {
Action::None
}
Event::Status(s) => {
self.templates_blocked = false;
self.last_status_s = Some(now_s);
if s.hash_now > 0.0 {
self.zero_since = None;
@ -207,9 +230,30 @@ impl CardWatch {
*self = Self::default();
Action::None
}
Event::TemplateTimeout => {
// the miner's own heartbeat: every silence clock starts over from this line
self.started_s = Some(now_s);
if self.last_status_s.is_some() {
self.last_status_s = Some(now_s);
}
self.zero_since = None;
self.templates_blocked = true;
Action::None
}
Event::NodeSynced => {
if self.faulted.as_deref().map(|f| NODE_FAULTS.iter().any(|p| f.starts_with(p))).unwrap_or(false) {
*self = Self::default();
}
Action::None
}
}
}
/// True when the card is faulted for a reason the node's readiness explains (released by Event::NodeSynced).
pub fn faulted_by_node(&self) -> bool {
self.faulted.as_deref().map(|f| NODE_FAULTS.iter().any(|p| f.starts_with(p))).unwrap_or(false)
}
/// Called every engine tick while the miner process is alive.
pub fn tick(&mut self, now_s: f64, node_synced: bool) -> Action {
if self.faulted.is_some() {
@ -223,14 +267,23 @@ impl CardWatch {
}
return Action::None;
}
if !node_synced {
// a miner is judged only while the node is synced: with no template it cannot print status, so the silence
// clocks (start, no status, zero rate) hold at now until the node is back (PC 1, 7 October 2026)
self.started_s = Some(now_s);
if self.last_status_s.is_some() {
self.last_status_s = Some(now_s);
}
self.zero_since = None;
return Action::None;
}
if !self.ready && self.last_status_s.is_none() && now_s - started > START_S {
return self.escalate(format!("the worker gave no status within {} s of starting", START_S as u64));
}
let last = self.last_status_s.unwrap_or(started);
if now_s - last > NO_STATUS_S {
return self.escalate(format!("no status line from the miner for {} s", NO_STATUS_S as u64));
}
if !node_synced {
// a zero rate while the node syncs is expected; the timer starts again once it is synced
self.zero_since = None;
}
if node_synced && self.ready {
if let Some(z) = self.zero_since {
if now_s - z >= ZERO_RATE_S {
@ -488,6 +541,70 @@ mod tests {
assert!(matches!(w.tick(240.0, true), Action::Restart(_)));
}
/// PC 1, 7 October 2026 04:51:43Z (docs/plans/release-0.3.17.md, the PC 1 row): the app relaunched after the OTA
/// apply, started its workers while the node was still syncing, and every card ended "faulted: no status line from
/// the miner for 90 s (restarted once already)", pid 0, 0.0 MH/s from then on. Today's watchdog clocks the silence
/// while the node is not synced and never releases the fault.
#[test]
fn a_worker_started_while_the_node_syncs_is_not_faulted_and_a_faulted_one_returns_at_sync() {
// the recorded sequence (relaunch-engine-and-workers.txt): node synced=true from the first second, the miner
// started at +14 s, subscribed, then every template fetch timed out at 5 s for 180 s; no status line was printed
let mut r = CardWatch::new();
r.event(14.0, Event::Started);
let mut t = 19.0;
while t <= 200.0 {
r.event(t, Event::TemplateTimeout);
assert_eq!(r.tick(t + 1.0, true), Action::None, "at {t}: a miner whose template fetches time out is not faulted on silence");
assert!(r.templates_blocked());
t += 5.0;
}
// the node answers templates again: the miner prints status and is healthy
healthy(&mut r, 210.0, 400.0);
assert!(!r.templates_blocked());
assert_eq!(r.faulted(), None);
// a miner that stops printing even timeouts is judged again from its last line
let mut q = CardWatch::new();
q.event(0.0, Event::Started);
q.event(50.0, Event::TemplateTimeout);
assert_eq!(q.tick(109.0, true), Action::None);
assert!(matches!(q.tick(111.0, true), Action::Restart(_)), "60 s of nothing at all after the last timeout line");
let mut w = CardWatch::new();
w.event(0.0, Event::Started);
// the node reads unsynced for 200 s (the relaunch's sync): no verdict at all
let mut t = 5.0;
while t <= 200.0 {
assert_eq!(w.tick(t, false), Action::None, "at {t}: a miner is judged only while the node is synced");
t += 10.0;
}
// the node syncs; the worker answers inside 60 s and is healthy
assert_eq!(w.tick(210.0, true), Action::None);
w.event(230.0, Event::Ready);
healthy(&mut w, 240.0, 400.0);
assert_eq!(w.faulted(), None);
// the recorded state: a card faulted for silence while the node was not ready
let mut f = CardWatch::new();
f.event(0.0, Event::Started);
assert!(matches!(f.tick(100.0, true), Action::Restart(_)));
f.event(101.0, Event::Started);
let a = f.tick(200.0, true);
assert!(matches!(a, Action::Fault(ref r) if (r.starts_with("no status line from the miner") || r.starts_with("the worker gave no status within")) && r.contains("restarted once already")), "{a:?}");
assert!(f.faulted_by_node());
f.event(300.0, Event::NodeSynced);
assert_eq!(f.faulted(), None, "the node syncing releases a silence fault");
assert_eq!(f.watchdog_restarts(), 0);
// a fault the card owns (zero rate while synced) stays through NodeSynced
let mut z = CardWatch::new();
z.event(0.0, Event::Started);
z.event(1.0, Event::Ready);
let zero = parse_status(STATUS_OK.replace("now=124.10 MH/s", "now=0.00 MH/s").as_str()).unwrap();
for t in [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0] { z.event(t, Event::Status(&zero)); z.tick(t, true); }
assert!(z.faulted().is_none() || !z.faulted_by_node());
let before = z.faulted().map(|s| s.to_string());
z.event(500.0, Event::NodeSynced);
assert_eq!(z.faulted().map(|s| s.to_string()), before);
}
#[test]
fn no_status_for_90s_restarts() {
let mut w = CardWatch::new();
@ -502,10 +619,11 @@ mod tests {
#[test]
fn no_status_from_the_start() {
// a worker silent from its start, with the node synced, is judged at START_S (60 s), not NO_STATUS_S
let mut w = CardWatch::new();
w.event(0.0, Event::Started);
assert_eq!(w.tick(89.0, true), Action::None);
assert!(matches!(w.tick(91.0, true), Action::Restart(_)));
assert_eq!(w.tick(59.0, true), Action::None);
assert!(matches!(w.tick(61.0, true), Action::Restart(ref r) if r.contains("gave no status within 60 s")));
}
#[test]

View file

@ -344,8 +344,9 @@ var View = (function () {
// MH per watt: the engine's live figure, else rate over draw
function effOf(cd) { if (cd.eff_mhw > 0) return cd.eff_mhw; return cd.power_w > 0 && cd.hash_now > 0 ? cd.hash_now / cd.power_w : 0; }
function effText(e) { return e > 0 ? (e >= 10 ? e.toFixed(1) : e.toFixed(2)) + ' MH/W' : ''; }
// the watts drawn now by the present cards
function wattsTotal(cards) { return (cards || []).filter(present).reduce(function (a, c) { return a + (c.power_w > 0 ? c.power_w : 0); }, 0); }
// the watts drawn now by the cards that are mining or tuning (an idle card's standby draw is not the fleet's cost;
// the engine's mining.watts_total counts the same way)
function wattsTotal(cards) { return (cards || []).filter(function (c) { return present(c) && (c.state === 'mining' || c.state === 'tuning'); }).reduce(function (a, c) { return a + (c.power_w > 0 ? c.power_w : 0); }, 0); }
// why a row has no power or temperature cell: one sentence, once, instead of three n/a
function noReading(cd) {
if (cd.power_w > 0 || cd.temp_gpu > 0) return '';
@ -450,7 +451,7 @@ var View = (function () {
if (!on) return { label: 'Start mining', sub: cards.length ? 'switch a card on in Cards first' : 'no card this app can use', cls: '', disabled: true, act: '' };
if (m.paused) return { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' };
if (m.state === 'mining') return { label: 'Stop mining', sub: 'mining on ' + mining + ' of ' + on + ' card' + (on === 1 ? '' : 's'), cls: 'stop', disabled: false, act: 'pause' };
if (m.state === 'waiting') return { label: 'Stop mining', sub: n && n.synced ? 'card starting' : 'waiting for the node to sync', cls: 'stop', disabled: false, act: 'pause' };
if (m.state === 'waiting') return { label: 'Stop mining', sub: n && n.synced ? 'card starting' : n && n.state === 'behind' ? 'waiting: the node is behind the chain' : n && n.state === 'no peers' ? 'waiting: no peers on our chain' : 'waiting for the node to sync', cls: 'stop', disabled: false, act: 'pause' };
return { label: 'Stop mining', sub: m.state || 'starting', cls: 'stop', disabled: false, act: 'pause' };
}
// the row's state word while the miner is paused: the card is on, the miner is not
@ -461,7 +462,13 @@ var View = (function () {
var bad = clock && clock.severity === 'block';
if (bad) return { word: n.state === 'synced' ? 'synced' : n.state, line: 'Your clock is off by ' + skewWord(clock.skew_s) + '. Mining waits until it is synced.', tone: 'bad' };
switch (n.state) {
case 'synced': return { word: 'synced', line: 'This node has every block the network has made.' + (n.last_reading_age_s >= 0 ? ' Read ' + Math.round(n.last_reading_age_s) + ' s ago.' : ''), tone: 'ok' };
case 'synced': return { word: 'synced', line: 'This node has every block the network has made.' + (n.tip_age_s >= 0 ? ' Last block ' + Math.round(n.tip_age_s) + ' s ago.' : n.last_reading_age_s >= 0 ? ' Read ' + Math.round(n.last_reading_age_s) + ' s ago.' : ''), tone: 'ok' };
// N4 (6 October 2026): a tip that stopped moving is never "synced"; the node says why
case 'behind':
// 7 October 2026: a node whose blocks never merge into the network (src/merge.rs) is behind with a tip that still moves
if (n.sync_cause === 'not merging') return { word: 'behind', line: 'Your blocks are not merging into the network: every other node is more than the merge depth ahead. Mining pauses until this node merges again; a slow link is the usual cause.', tone: 'bad' };
return { word: 'behind', line: (n.tip_age_s >= 0 ? 'No new block for ' + tipAge(n.tip_age_s) + '. ' : '') + 'The chain may have moved on without this node. Mining pauses until it catches up; the node re-dials its peers by itself.', tone: 'bad' };
case 'no peers': return { word: 'no peers', line: 'No other node on our chain is connected. The node keeps dialling the seed list; mining waits.', tone: 'bad' };
case 'syncing':
var pct = n.headers > n.blocks && n.headers > 0 ? Math.floor(n.blocks / n.headers * 100) : 0;
var line = n.headers > n.blocks ? (eta || n.message || 'downloading blocks') + ' · ' + withCommas(n.blocks) + ' of ' + withCommas(n.headers) + ' (' + pct + '%)' : (n.message || 'caught up, waiting for the next block');
@ -469,15 +476,19 @@ var View = (function () {
case 'starting': return { word: 'starting', line: n.message || 'opening the database', tone: '' };
case 'restarting': return { word: 'restarting', line: 'restart in ' + (n.restart_in_s || 0) + ' s' + (n.message ? ' · ' + n.message : ''), tone: 'bad' };
case 'failed': return { word: 'failed', line: n.message || 'the node could not start', tone: 'bad' };
case 'stopped': return { word: 'stopped', line: n.message || 'the node is not running', tone: 'bad' };
case 'stopped': return { word: n.source === 'none' ? 'not started' : 'stopped', line: n.message || 'the node is not running', tone: 'bad' };
case 'stub': return { word: 'stub engine', line: 'This node was built without the mining engine, so it refuses every real block. Reinstall the app.', tone: 'bad' };
}
return { word: n.state || 'starting', line: n.message || '', tone: '' };
}
// the one node line on Mine: "Node synced · 6 peers · 109,859 blocks"
// "12 s" / "4 min" / "53 min" / "2 h" for a tip age
function tipAge(s) { s = Math.max(0, Math.round(s)); return s < 90 ? s + ' s' : s < 5400 ? Math.round(s / 60) + ' min' : Math.round(s / 3600) + ' h'; }
// the one node line on Mine: "Node synced · 6 peers · 109,859 blocks · last block 4 s ago"
function nodeLine(n, clock, eta) {
var w = nodeWords(n, clock, eta), parts = ['Node ' + w.word];
if (n.peers > 0) parts.push(n.peers + ' peer' + (n.peers === 1 ? '' : 's'));
if (n.blocks > 0) parts.push(withCommas(n.blocks) + ' blocks');
if (n.tip_age_s >= 0 && (n.state === 'synced' || n.state === 'behind')) parts.push('last block ' + tipAge(n.tip_age_s) + ' ago');
return { text: parts.join(' · '), sub: w.line, tone: w.tone };
}
function skewWord(sk) { sk = Math.round(Math.abs(sk || 0)); return sk < 120 ? sk + ' s' : sk < 7200 ? Math.floor(sk / 60) + ' min ' + (sk % 60) + ' s' : Math.floor(sk / 3600) + ' h ' + Math.floor(sk % 3600 / 60) + ' min'; }
@ -653,8 +664,11 @@ var View = (function () {
if (n.state === 'syncing') return { text: 'Syncing the node', tone: '' };
if (n.state === 'starting') return { text: 'Starting the node', tone: '' };
if (n.state === 'restarting') return { text: 'Node restarting', tone: 'bad' };
if (n.state === 'behind') return { text: 'Node behind', tone: 'bad' };
if (n.state === 'no peers') return { text: 'No peers', tone: 'bad' };
if (n.state === 'failed') return { text: 'Node failed', tone: 'bad' };
if (n.state === 'stopped') return { text: 'Node stopped', tone: 'bad' };
if (n.state === 'stopped') return { text: n.source === 'none' ? 'Node not started' : 'Node stopped', tone: 'bad' };
if (n.state === 'stub') return { text: 'Node fault', tone: 'bad' };
if (m.state === 'waiting') return { text: 'Starting the worker', tone: '' };
if (m.state === 'idle') return { text: 'Not mining', tone: '' };
return { text: cap(m.state || 'starting'), tone: '' };
@ -695,7 +709,7 @@ var View = (function () {
}
return out;
}
out.label = st.sweep === false ? 'Not tuned: Ember Tune is off' : 'Not tuned yet'; out.sub = 'starts after 2 min of steady mining'; return out;
out.label = st.sweep === false ? 'Not tuned: Ember Tune is off' : 'Not tuned yet'; out.sub = st.sweep === false ? 'Tune starts one by hand' : 'starts after 2 min of steady mining'; return out;
}
// the Tuning strip's fleet saving: watts and £ a day summed over the tuned cards
function fleetSaving(cards, s, now, pence) {
@ -705,10 +719,63 @@ var View = (function () {
var ppd = poundsPerDay(w, pence);
return 'Ember saves ' + Math.round(w) + ' W' + (ppd !== null ? ', ' + money(ppd) + ' a day' : '') + ' across ' + n + ' tuned card' + (n === 1 ? '' : 's');
}
// ---- the engine's 0.3.16 fields (ember-tune fd03f35): mining.watts_total, mining.pounds_per_day, address.balance_wei
// (string | null), address.balance_age_s (-1 until read), address.balance_note, address.price_gbp_per_ign
// (null until a signed manifest carries a price), the chain scene's reply.source ("node" | "site") ----
function ign(wei) { var n = Number(wei); return isNaN(n) ? null : n / 1e18; }
// the Earnings balance row: {text, sub, pounds} ; the UI shows "not read yet" until the first read
function balanceWords(a, now) {
a = a || {};
if (a.balance_wei === undefined) return { text: '', sub: '', pounds: null };
if (a.balance_wei === null || a.balance_wei === '') return { text: 'not read yet', sub: a.balance_note || 'the node answers the balance once it is synced', pounds: null };
var v = ign(a.balance_wei); if (v === null) return { text: 'not read yet', sub: a.balance_note || '', pounds: null };
var age = a.balance_age_s >= 0 ? ' · read ' + ago(a.balance_age_s) : '';
var price = a.price_gbp_per_ign;
return { text: (v >= 1000 ? withCommas(v) : v.toFixed(4)) + ' IGN', sub: 'in the wallet' + age + (a.balance_note ? ' · ' + a.balance_note : ''), pounds: price > 0 ? v * price : null };
}
// the fleet watts and £ a day: the engine's figures when it carries them, else the UI's own sums
function fleetWatts(m, cards) { return m && m.watts_total > 0 ? m.watts_total : wattsTotal(cards); }
function fleetPounds(m, cards, pence) { if (m && m.pounds_per_day > 0) return m.pounds_per_day; return poundsPerDay(fleetWatts(m, cards), pence); }
// is a lane key this machine's? The cards' ids are the first 8 hex of each vote key hash; a source may shorten the
// hash to another length (the 0.3.16 engine keeps 12), so either prefix of the other counts
function isMine(miner, cards) {
var m = String(miner || '').replace(/^0x/, '').toLowerCase(); if (!m) return false;
var head = m.split('..')[0]; // the 0.3.16 engine shortens a long hash to "head6..tail6"; the site keeps 8 hex
return (cards || []).some(function (c) { return (c.ids || []).some(function (id) {
id = String(id).replace(/^0x/, '').toLowerCase(); if (!id) return false;
var n = Math.min(head.length, id.length); return n >= 6 && head.slice(0, n) === id.slice(0, n);
}); });
}
// the chain scene's note from the reply's source
function liveNote(reply) {
if (!reply) return '';
if (reply.pending) return 'connecting';
var src = reply.source || ((reply.state || {}).source);
if (src === 'node') return 'From your node. Your blocks are ringed.';
if (src === 'site' || src === 'observer') return 'From Igneum’s observer. Your blocks are ringed.';
return '';
}
// finality (ember-tune 1357d280, folded in at the rebase onto a9eb58f1): f = state.finality. While the network's finality is paused
// (under two thirds of the weight signing) the node line's sub-text and the lock row say so, and no surface calls a
// lock "final" (main, Q83/Q84). {paused, age, note}
function finalityWords(f) {
f = f || {};
if (f.paused) return { paused: true, age: 'paused', note: f.line || 'Finality paused: under two thirds of the weight is signing' };
if (f.last_lock > 0) return { paused: false, age: rel(f.age_s), note: 'A lock is a point the miners have agreed can never be undone. Checkpoints lock at 2/3 of the voting weight.' };
return { paused: false, age: 'n/a', note: f.message || 'Locks appear once the miner votes on checkpoints.' };
}
// whose node the app reads (publish 2's read-back, 6 October 2026): n.source app | external | none | ""
function nodeSource(n) {
n = n || {};
if (n.source === 'app') return { text: 'this app’s own node', sub: n.mode_reason || n.port_note || '' };
if (n.source === 'external') return { text: 'another node on this machine', sub: n.rules_check === 'match' ? 'same network and rules, checked' : n.rules_check === 'unknown' ? 'its rules could not be checked (an older node)' : (n.port_note || '') };
if (n.source === 'none') return { text: 'no node', sub: n.port_note || n.mode_reason || 'the ports are taken by a node on other rules' };
return { text: '', sub: '' };
}
// the confirmations' words (in place, never a dialog)
function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? Mining restarts.'; }
function trustAsk(on) { return on ? 'Include proofs the node never checked? Devnet only. The node restarts.' : 'Check proofs again? The node restarts.'; }
return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk };
return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk };
})();
/* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ----------
One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned
@ -1318,7 +1385,8 @@ if (typeof document !== 'undefined') (function () {
function mountDag() {
if (dag || typeof IgneumDag === 'undefined' || !$('dag-live')) return;
try {
dag = IgneumDag.mount($('dag-live'), { compact: window.innerWidth < 720, window: 120, url: 'api/live', mine: 'you', tooltip: $('dag-tip'), chip: $('dag-chip'), pauseButton: $('dag-pause'), onState: function (st, why) {
var mine = function (b) { if (!b) return false; if (b.miner === 'you') return true; if (!state) return false; return View.isMine(b.miner, state.mining.cards); };
dag = IgneumDag.mount($('dag-live'), { compact: window.innerWidth < 720, window: 120, url: 'api/live', mine: mine, tooltip: $('dag-tip'), chip: $('dag-chip'), pauseButton: $('dag-pause'), onData: function (d) { var n = View.liveNote(d); if (n && dagMode !== 'failed') setText('dag-note', n); }, onState: function (st, why) {
var live = st === 'live' || st === 'stale';
if (dagMode !== st) { dagMode = st; setText('dag-state', st === 'live' ? 'live' : st === 'stale' ? 'behind' : st === 'failed' ? 'your blocks only' : 'connecting'); setText('dag-note', st === 'failed' ? 'The full chain view needs the next update. This is the last 10 minutes of your own blocks.' : st === 'stale' ? (why || '') : ''); }
$('dag-live').hidden = !live; $('dag-tip').hidden = true; $('dag-pause').hidden = !live; $('dag').hidden = live;
@ -1499,9 +1567,9 @@ if (typeof document !== 'undefined') (function () {
// the live total: rate, watts, £ a day, blocks
setText('t-hash', m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1));
setText('t-hash-sub', m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + presentCards.length + ' card' + (presentCards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'card starting' : 'waiting for the node') : m.state === 'idle' ? 'no card mining' : m.state);
var watts = View.wattsTotal(m.cards), price = priceNow(), eff = watts > 0 && m.hash_total > 0 ? View.effText(m.hash_total / watts) : '';
var watts = View.fleetWatts(m, m.cards), price = priceNow(), eff = watts > 0 && m.hash_total > 0 ? View.effText(m.hash_total / watts) : '';
setHidden('t-power-cell', !(watts > 0)); setText('t-power', String(Math.round(watts))); setText('t-power-sub', eff || 'drawn now');
var ppd = View.poundsPerDay(watts, price);
var ppd = View.fleetPounds(m, m.cards, price);
setHidden('t-money-cell', !(watts > 0)); setText('t-money', ppd === null ? 'Set a price' : money(ppd)); $('t-money').classList.toggle('ask-price', ppd === null); setText('t-money-sub', ppd === null ? 'in Settings, pence per kWh' : 'electricity at ' + price + 'p/kWh');
setText('t-blocks', withCommas(m.accepted_session)); setText('t-blocks-sub', 'this run · ' + withCommas(m.accepted_total) + ' lifetime' + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : ''));
// the rows: every card, on or off; rebuilt only when their words change and never under a hand on a slider
@ -1548,11 +1616,12 @@ if (typeof document !== 'undefined') (function () {
var switchesSig = '';
function renderNode(s) {
var n = s.node, f = s.finality, p = s.program, eta = syncEta(n, s.now);
var nl = View.nodeLine(n, s.clock, eta);
setText('node-line-text', nl.text); setText('node-sub', nl.sub);
var nl = View.nodeLine(n, s.clock, eta), fw = View.finalityWords(f);
setText('node-line-text', nl.text); setText('node-sub', fw.paused && nl.tone !== 'bad' ? fw.note : nl.sub);
$('node-dot').className = 'dot' + (nl.tone === 'ok' ? ' live' : nl.tone === 'bad' ? ' bad' : '');
$('node-card').classList.toggle('bad', nl.tone === 'bad');
setText('n-state-v', nl.text.replace(/^Node /, '')); setText('n-state-m', nl.sub + (n.override_restart_wait ? ' ' + n.override_restart_wait + '.' : ''));
var ns = View.nodeSource(n); setText('n-source', ns.text); setText('n-source-m', ns.sub); setHidden('n-source', !ns.text);
setText('n-blocks', withCommas(n.blocks)); setText('n-blocks-sub', View.heightLine(n));
setText('n-peers', String(n.peers)); setText('n-peers-sub', View.peersLine(n));
setText('n-version', n.version ? n.version.replace(/^igneumd\s*/, '') : '--');
@ -1569,8 +1638,9 @@ if (typeof document !== 'undefined') (function () {
switchesSig = sig;
$('n-switches').innerHTML = (n.consensus_switches || []).map(function (x) { return '<span class="' + (sw && x.key === sw.key ? 'next' : x.daa <= n.daa ? 'past' : '') + '"><span>' + esc(x.name) + (x.daa <= n.daa ? ' (applied)' : '') + '</span><span>' + withCommas(x.daa) + '</span></span>'; }).join('');
}
if (f.last_lock > 0) { setText('f-lock', '#' + withCommas(f.last_lock) + ' · ' + rel(f.age_s)); setText('f-note', 'a point the miners agreed can never be undone. It locks at two thirds of the last 30 days of mining'); }
else { setText('f-lock', 'none yet'); setText('f-note', f.message || 'checkpoints lock once this machine and the others vote'); }
setText('f-lock', (f.last_lock > 0 ? '#' + withCommas(f.last_lock) : 'none yet') + ' · ' + fw.age);
setText('f-note', fw.paused ? fw.note : f.last_lock > 0 ? 'a point the miners agreed can never be undone. It locks at two thirds of the last 30 days of mining' : (f.message || 'checkpoints lock once this machine and the others vote'));
$('f-lock').classList.toggle('warn', !!fw.paused);
setText('f-votes', withCommas(f.votes));
}
@ -1580,7 +1650,9 @@ if (typeof document !== 'undefined') (function () {
var ign = pv.paid_wei && Number(pv.paid_wei) ? (Number(pv.paid_wei) / 1e18).toFixed(4) : '0.0000';
setText('e-ign', ign + ' IGN'); setText('e-ign-sub', 'from ' + (pv.paid || 0) + ' proof' + (pv.paid === 1 ? '' : 's') + (pv.segments_paid ? ' and ' + pv.segments_paid + ' segment' + (pv.segments_paid === 1 ? '' : 's') : ''));
setText('e-blocks', withCommas(m.accepted_total) + ' block' + (m.accepted_total === 1 ? '' : 's')); setText('e-blocks-sub', 'lifetime · ' + withCommas(m.accepted_session) + ' this run, ' + uptime(s.uptime_s));
var watts = View.wattsTotal(m.cards), ppd = View.poundsPerDay(watts, price);
var watts = View.fleetWatts(m, m.cards), ppd = View.fleetPounds(m, m.cards, price);
var bw = View.balanceWords(a, s.now);
if (bw.text) { setText('e-earned', bw.pounds !== null ? money(bw.pounds) : bw.text); setText('e-earned-sub', bw.pounds !== null ? 'at the signed price · ' + bw.text + ' ' + bw.sub : (bw.text === 'not read yet' ? 'balance ' + bw.sub : bw.sub + '. Nothing is bought or sold on devnet')); }
setText('e-cost', ppd === null ? (watts > 0 ? Math.round(watts) + ' W' : 'no draw') : money(ppd) + ' a day');
setText('e-cost-sub', ppd === null ? (watts > 0 ? 'electricity: set a price to see the cost' : 'electricity: no card reports its draw') : 'electricity at ' + price + 'p/kWh for ' + Math.round(watts) + ' W');
setHidden('e-price-btn', !(watts > 0 && ppd === null));

View file

@ -204,6 +204,7 @@
<div class="details" id="node-details" hidden>
<div class="kv">
<div><span class="k">state</span><span class="v mono" id="n-state-v"></span><span class="m" id="n-state-m"></span></div>
<div><span class="k">whose node</span><span class="v" id="n-source"></span><span class="m" id="n-source-m"></span></div>
<div><span class="k">height</span><span class="v mono" id="n-blocks">0</span><span class="m" id="n-blocks-sub">blocks this node holds</span></div>
<div><span class="k">peers</span><span class="v mono" id="n-peers">0</span><span class="m" id="n-peers-sub">other nodes it talks to</span></div>
<div><span class="k">version</span><span class="v mono" id="n-version">--</span><span class="m" id="d-node-net">devnet v4</span></div>

View file

@ -233,6 +233,7 @@ test('money: £ a day from watts at a price, nothing without one, the cells leav
assert.equal(apple.noReading, 'No power or temperature reading on Apple silicon');
assert.equal(V.cells(card({ kind: 'integrated', vendor: 'other', power_w: 0, temp_gpu: 0, eff_mhw: 0 }), 28).noReading, 'No power or temperature reading on an integrated GPU');
assert.equal(V.wattsTotal([card(), card({ key: 'b', power_w: 100 }), card({ key: 'c', power_w: 50, removed_at: 5 })]), 510.2);
assert.equal(V.wattsTotal([card(), card({ key: 'd', power_w: 48, state: 'off', enabled: false })]), 410.2, 'an idle card’s standby draw is not counted');
assert.equal(V.effText(V.effOf(card({ eff_mhw: 0.422 }))), '0.42 MH/W');
assert.equal(V.effText(V.effOf(card({ eff_mhw: 0, power_w: 0 }))), '');
});
@ -369,9 +370,98 @@ test('Ember on the row (miner-ui-4): the flame fill, before -> after, the saving
const measured = V.ember(card({ tune_line: 'Measured: 122.3 MH/s at 410 W (0.298 MH/W)', tune_source: 'baseline', tune_control: false, sweep_at: NOW - 600, sweep_note: 'measure only until Power control is on in Settings (Windows asks for administrator rights once)', sweep_state: 'idle' }), settings({ power_control: false }), NOW);
assert.equal(measured.kind, 'measured'); assert.equal(measured.label, 'Measured, not tuned'); assert.equal(measured.sub, 'Power control is off'); assert.equal(measured.fill, 0.5);
const idle = V.ember(card({ sweep_state: 'idle', sweep_note: '', tune_line: '', sweep_at: 0 }), settings(), NOW);
assert.equal(idle.kind, 'idle'); assert.equal(idle.fill, 0); assert.equal(idle.label, 'Not tuned yet');
assert.equal(idle.kind, 'idle'); assert.equal(idle.fill, 0); assert.equal(idle.label, 'Not tuned yet'); assert.equal(idle.sub, 'starts after 2 min of steady mining');
const off = V.ember(card({ sweep_state: 'idle', sweep_note: '', tune_line: '', sweep_at: 0 }), settings({ sweep: false }), NOW);
assert.equal(off.label, 'Not tuned: Ember Tune is off'); assert.equal(off.sub, 'Tune starts one by hand');
assert.equal(V.ember(card({ vendor: 'other', kind: 'integrated', sweep_supported: false }), settings(), NOW).kind, 'none');
assert.equal(V.fleetSaving([tuned, card({ key: 'b', sweep_state: 'idle', tune_line: '', sweep_at: 0 })], settings(), NOW, 28), 'Ember saves 84 W, £0.56 a day across 1 tuned card');
assert.equal(V.fleetSaving([card({ sweep_state: 'idle', tune_line: '', sweep_at: 0 })], settings(), NOW, 28), '');
assert.deepEqual(V.beforeOf({ tune_before_watts: 0, tune_curve: curve }), null);
});
test('the 0.3.16 engine fields (ember-tune fd03f35): the balance row, the fleet totals, the chain scene note', () => {
assert.deepEqual(V.balanceWords({}, NOW), { text: '', sub: '', pounds: null }, 'an older engine has no balance field: the row stays hidden');
const unread = V.balanceWords({ balance_wei: null, balance_age_s: -1, balance_note: '', price_gbp_per_ign: null }, NOW);
assert.equal(unread.text, 'not read yet'); assert.equal(unread.pounds, null);
const read = V.balanceWords({ balance_wei: '12345600000000000000', balance_age_s: 20, balance_note: '', price_gbp_per_ign: null }, NOW);
assert.equal(read.text, '12.3456 IGN'); assert.equal(read.sub, 'in the wallet · read just now'); assert.equal(read.pounds, null);
const priced = V.balanceWords({ balance_wei: '12345600000000000000', balance_age_s: 90, balance_note: '', price_gbp_per_ign: 0.5 }, NOW);
assert.equal(priced.pounds.toFixed(2), '6.17'); assert.equal(priced.sub, 'in the wallet · read 1 min ago');
assert.equal(V.balanceWords({ balance_wei: '5000000000000000000000', balance_age_s: 3, balance_note: 'the node answered 0x for the balance, not a quantity' }, NOW).sub, 'in the wallet · read just now · the node answered 0x for the balance, not a quantity');
assert.equal(V.balanceWords({ balance_wei: '5000000000000000000000', balance_age_s: 3 }, NOW).text, '5,000 IGN');
assert.equal(V.ign('1500000000000000000'), 1.5); assert.equal(V.ign('x'), null);
const cards = [card(), card({ key: 'b', power_w: 100 })];
assert.equal(V.fleetWatts({ watts_total: 0 }, cards), 510.2, 'no engine figure: the UI sums the cards');
assert.equal(V.fleetWatts({ watts_total: 629 }, cards), 629, 'the engine figure wins');
assert.equal(V.fleetPounds({ watts_total: 629, pounds_per_day: 4.23 }, cards, 28), 4.23);
assert.equal(V.fleetPounds({ watts_total: 629, pounds_per_day: 0 }, cards, 28).toFixed(2), '4.23', 'no engine £: computed from the watts and the price');
assert.equal(V.fleetPounds({}, cards, 0), null);
assert.equal(V.liveNote({ ok: true, source: 'node', blocks: [] }), 'From your node. Your blocks are ringed.');
assert.equal(V.liveNote({ ok: true, source: 'site', blocks: [] }), 'From Igneum’s observer. Your blocks are ringed.');
assert.equal(V.liveNote({ ok: true, state: { source: 'site' } }), 'From Igneum’s observer. Your blocks are ringed.', 'the 0.3.15 engine carries the source under state');
assert.equal(V.liveNote({ ok: false, pending: true, source: 'site' }), 'connecting');
assert.equal(V.liveNote(null), '');
const mineCards = [card({ ids: ['8fafda27', '1b2c3d4e'] })];
assert.equal(V.isMine('8fafda27', mineCards), true, 'the site: the first 8 hex of the vote key hash');
assert.equal(V.isMine('8fafda..e01f22', mineCards), true, 'the 0.3.16 engine: head6..tail6 of the same hash');
assert.equal(V.isMine('8fafda27a9c1', mineCards), true, 'a longer prefix of the same hash');
assert.equal(V.isMine('0x1B2C3D4E', mineCards), true);
assert.equal(V.isMine('8fafdb..e01f22', mineCards), false, 'one hex off');
assert.equal(V.isMine('deadbeef', mineCards), false);
assert.equal(V.isMine('8faf', mineCards), false, 'under 6 hex never matches');
assert.equal(V.isMine('', mineCards), false);
});
test('N4 (6 October 2026): a frozen tip reads behind, never synced; the tip age is on the node line', () => {
const n = (over) => ({ state: 'synced', blocks: 133000, headers: 133000, peers: 1, daa: 140000, last_reading_age_s: 4, tip_age_s: 3, message: '', restart_in_s: 0, ...over });
const ok = V.nodeLine(n(), { severity: 'none' }, '');
assert.equal(ok.text, 'Node synced · 1 peer · 133,000 blocks · last block 3 s ago'); assert.equal(ok.tone, 'ok');
assert.match(ok.sub, /Last block 3 s ago/);
const behind = V.nodeLine(n({ state: 'behind', synced: false, tip_age_s: 3180, sync_cause: 'frozen' }), { severity: 'none' }, '');
assert.equal(behind.text, 'Node behind · 1 peer · 133,000 blocks · last block 53 min ago'); assert.equal(behind.tone, 'bad');
assert.match(behind.sub, /^No new block for 53 min\. The chain may have moved on without this node\./);
const none = V.nodeLine(n({ state: 'no peers', synced: false, peers: 0, tip_age_s: 500 }), { severity: 'none' }, '');
assert.equal(none.text, 'No peers · 133,000 blocks'.replace('No peers', 'Node no peers')); assert.equal(none.tone, 'bad');
assert.match(none.sub, /^No other node on our chain is connected/);
// an older node without the age keeps the old words
assert.equal(V.nodeLine(n({ tip_age_s: -1 }), { severity: 'none' }, '').text, 'Node synced · 1 peer · 133,000 blocks');
assert.equal(V.tipAge(12), '12 s'); assert.equal(V.tipAge(240), '4 min'); assert.equal(V.tipAge(3180), '53 min'); assert.equal(V.tipAge(7300), '2 h');
const base = { setup_done: true, quitting: false, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'behind' } };
assert.deepEqual(V.pill(base), { text: 'Node behind', tone: 'bad' });
assert.deepEqual(V.pill({ ...base, node: { state: 'no peers' } }), { text: 'No peers', tone: 'bad' });
assert.equal(V.toggle({ state: 'waiting', paused: false, cards: [card({ state: 'waiting' })] }, { synced: false, state: 'behind' }, {}).sub, 'waiting: the node is behind the chain');
});
test('not merging (0.3.18 design note): behind with a moving tip, the miner held', () => {
const w = V.nodeWords({ state: 'behind', sync_cause: 'not merging', tip_age_s: 3, message: 'behind: your blocks are not merging into the network', blocks: 1, headers: 1, peers: 2 }, { severity: 'none' }, '');
assert.equal(w.word, 'behind'); assert.equal(w.tone, 'bad'); assert.match(w.line, /not merging into the network/); assert.doesNotMatch(w.line, /No new block/);
});
test('finality paused (ember-tune 1357d280): the words, no lock called final while paused', () => {
const p = V.finalityWords({ paused: true, last_lock: 412, age_s: 1200, line: 'Finality paused since 18:39 UTC: under two thirds of the weight is signing' });
assert.equal(p.paused, true); assert.equal(p.age, 'paused'); assert.equal(p.note, 'Finality paused since 18:39 UTC: under two thirds of the weight is signing');
assert.equal(/\bfinal\b/i.test(p.note), false);
assert.equal(V.finalityWords({ paused: true, last_lock: 0, age_s: 0 }).note, 'Finality paused: under two thirds of the weight is signing');
const ok = V.finalityWords({ paused: false, last_lock: 412, age_s: 90 });
assert.equal(ok.paused, false); assert.equal(ok.age, '1 min ago'); assert.match(ok.note, /never be undone/);
const none = V.finalityWords({ paused: false, last_lock: 0, message: 'waiting for the miner' });
assert.equal(none.age, 'n/a'); assert.equal(none.note, 'waiting for the miner');
// N5: a node built without the mining engine is a fault
assert.equal(V.nodeWords({ state: 'stub', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '').word, 'stub engine');
assert.deepEqual(V.pill({ setup_done: true, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'stub' } }), { text: 'Node fault', tone: 'bad' });
});
test('whose node (publish 2): the app’s own, another node on this machine checked or unchecked, or none when the ports are taken by other rules', () => {
assert.deepEqual(V.nodeSource({ source: 'app' }), { text: 'this app’s own node', sub: '' });
assert.deepEqual(V.nodeSource({ source: 'external', rules_check: 'match' }), { text: 'another node on this machine', sub: 'same network and rules, checked' });
assert.deepEqual(V.nodeSource({ source: 'external', rules_check: 'unknown' }), { text: 'another node on this machine', sub: 'its rules could not be checked (an older node)' });
assert.deepEqual(V.nodeSource({ source: 'none', port_note: 'Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)' }), { text: 'no node', sub: 'Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)' });
assert.deepEqual(V.nodeSource({}), { text: '', sub: '' });
// 7 October 2026: the mode is re-decided; after the other node leaves for 60 s the app runs its own and says why
assert.deepEqual(V.nodeSource({ source: 'app', mode: 'own', mode_reason: 'the node this app was reading left port 26611 for 60 s, so the app started its own node' }), { text: 'this app’s own node', sub: 'the node this app was reading left port 26611 for 60 s, so the app started its own node' });
const gone = V.nodeWords({ state: 'stopped', source: 'external', message: 'the other node on port 26611 went away; this app starts its own node in 45 s unless it comes back', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '');
assert.equal(gone.word, 'stopped'); assert.match(gone.line, /starts its own node in 45 s/);
const taken = V.nodeWords({ state: 'stopped', source: 'none', message: 'Node not started: port 26611 is taken by a node on other rules (fees_v1_activation_daa 200000, ours 210000)', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '');
assert.equal(taken.word, 'not started'); assert.equal(taken.tone, 'bad'); assert.match(taken.line, /^Node not started: port 26611 is taken/);
assert.deepEqual(V.pill({ setup_done: true, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'stopped', source: 'none' } }), { text: 'Node not started', tone: 'bad' });
});

View file

@ -209,6 +209,9 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
}
} else if line.hasPrefix("STATE ") {
applyState(String(line.dropFirst(6)))
} else if line.hasPrefix("SHOT ") {
// Miner UI 4 (b): the engine's POST /api/shot asks for a PNG of what the window shows, at this path
shotTo(String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces))
} else if line.hasPrefix("FATAL ") {
fail(String(line.dropFirst(6)))
} else if line == "EXIT" {
@ -322,6 +325,19 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
let a = NSAlert(); a.messageText = "Igneum Miner"; a.informativeText = message; a.runModal(); completionHandler()
}
// ---- a screenshot on request (the SHOT line), the app keeps running ----
func shotTo(_ path: String) {
let conf = WKSnapshotConfiguration()
conf.rect = web.bounds
web.takeSnapshot(with: conf) { image, error in
guard let img = image, let tiff = img.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), let png = rep.representation(using: .png, properties: [:]) else {
FileHandle.standardError.write("shot failed: \(error?.localizedDescription ?? "no image")\n".data(using: .utf8)!)
return
}
do { try png.write(to: URL(fileURLWithPath: path)) } catch { FileHandle.standardError.write("shot: could not write \(path): \(error)\n".data(using: .utf8)!) }
}
}
// ---- snapshot ----
func snapshot(to path: String) {
if let js = ProcessInfo.processInfo.environment["IGNEUM_PROBE"] {

View file

@ -16,6 +16,7 @@
#endif
#include <windows.h>
#include <shellapi.h>
#include <shlwapi.h>
#include <dbt.h>
#include <wrl.h>
#include <string>
@ -39,6 +40,13 @@ static HWND g_hwnd = nullptr;
static HANDLE g_engine = nullptr, g_engineIn = nullptr, g_engineOut = nullptr;
static ComPtr<ICoreWebView2Controller> g_controller;
static ComPtr<ICoreWebView2> g_webview;
// a PNG of the web view at `path` (the SHOT line); the stream is released by the completion handler
static void capturePreview(const std::wstring& path) {
if (!g_webview) return;
ComPtr<IStream> stream;
if (FAILED(SHCreateStreamOnFileEx(path.c_str(), STGM_CREATE | STGM_WRITE | STGM_SHARE_EXCLUSIVE, FILE_ATTRIBUTE_NORMAL, TRUE, nullptr, &stream))) return;
g_webview->CapturePreview(COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_PNG, stream.Get(), Callback<ICoreWebView2CapturePreviewCompletedHandler>([stream](HRESULT) -> HRESULT { return S_OK; }).Get());
}
static std::wstring g_url, g_status = L"starting the engine";
static bool g_paused = false, g_quitting = false, g_exited = false, g_webviewOk = false, g_hintShown = false;
static NOTIFYICONDATAW g_nid = {};
@ -308,6 +316,9 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
applyState(line->substr(6));
} else if (line->rfind("ELEVATE ", 0) == 0) {
runElevated(widen(line->substr(8)));
} else if (line->rfind("SHOT ", 0) == 0) {
// Miner UI 4 (b): the engine's POST /api/shot asks for a PNG of what the window shows, at this path
capturePreview(widen(line->substr(5)));
} else if (line->rfind("FATAL ", 0) == 0) {
g_status = widen(line->substr(6));
repaintStatus();

View file

@ -66,3 +66,52 @@ Tests: `node --test app/igneum-app/ui/*.test.mjs`: 37 pass.
The Rust tests did not run on this Mac (its cargo predates the lock file's version 4); the box runs them at the
merge, as the rule says. UI tests: 37 pass. Screenshots `u01`, `u03`, `u04`, `u06` reshot after the sweep.
## 7. Pinned to the next engine (ember-tune fd03f35, then b55bc1f), 20:5x UK
Numbering (the shipper, 21:3x UK): 0.3.16 is tonight's corrective cut, the same release as 0.3.15 (this branch's ui/
at 872f1c9) under a new number; the engine fields and the pinning below ship as 0.3.17 with miner-ui-4's tip e31acab.
| Field | Where the UI reads it |
|---|---|
| `mining.watts_total`, `mining.pounds_per_day` | the Overview hero and Earnings use the engine's figures when present (`View.fleetWatts`, `View.fleetPounds`), else the UI's own sums as before |
| `address.balance_wei` (string or null), `address.balance_age_s` (-1 until read), `address.balance_note`, `address.price_gbp_per_ign` (null until a signed manifest carries a price) | Earnings' first row: "12.3456 IGN · in the wallet · read 20 s ago" (`View.balanceWords`); a priced balance shows £ with "at the signed price"; an older engine without the field keeps the row as it was |
| `card.tune_floor` | the Ember line's "(the lowest clock Ember will set)" |
| `/api/live` reply `source` ("node" when igneumd answers `igneum_getRecentBlocks`, "site" until then; `pending: true` before the first body) | the chain scene's note (`View.liveNote`): "From your node. Your blocks are ringed." or "From Igneum’s observer. Your blocks are ringed."; "you" is matched in the UI from every card's vote-key ids whatever the source names the lane |
At the 0.3.17 merge `src/live.rs` is ember-tune's (the fuller one, node first, site second); this branch's 0.3.15
file is superseded and its `source` word is "site" too, so nothing in the UI changes between the two.
## 8. N4 (the frozen tip), the capture route, and PC 2's light render (6 October 2026, 23:0x to 23:4x UK)
| What | Where |
|---|---|
| N4, the app half: `sync_decision_v2` (the old rule AND the watch line's `tip_age_s` at or under 120 s AND peers >= 1; the cause word frozen / no peers / syncing / behind on `node.sync_cause`; the state word "behind" or "no peers", never "synced" on a frozen tip); `is_stall_exit` (code 45 or a "STALLED '" tail line; the first restarts the miner, the second since the node started restarts the node and re-dials its peers); `node.tip_age_s`, `node.stall_exits`. The known-failed case is the first test. UI: "Node behind · 1 peer · 133,000 blocks · last block 53 min ago" with the cause line, the pill "Node behind", the big button's "waiting: the node is behind the chain". The node lane's miner half is ca3-v4-0316. | `src/engine.rs`, `src/state.rs`, `ui/app.js`, `view.test.mjs` |
| (b) a screenshot from the machine itself: `POST /api/shot {name}` writes `SHOT <path>` on the host protocol (beside URL, STATE, EXIT); the Mac host answers with `WKWebView.takeSnapshot`, the Windows host with WebView2 `CapturePreview` (PNG), to `<data root>/shots/igneum-<name>-<unix>.png` (`live::shot_path`, the name reduced to [a-z0-9-]); `relay/playbooks/app-shot.ps1` asks and waits, a collect job's glob `shots/*.png` brings the file back. Read-only. The Swift parses (`swiftc -parse`); the C++ builds on the GitHub runner at the cut (`BUILD-APP.bat` already links shlwapi.lib). | `src/server.rs`, `src/live.rs`, `app/mac/IgneumMiner.swift`, `app/windows/host.cpp`, `relay/playbooks/app-shot.ps1` |
| The light-theme showcase capture: Edge headless on PC 2 wrote nothing in three takes (154.0.4258.53, exit 0, no stderr, in the app's elevated session); PC 2's `api/state` and `api/live` were fetched by `relay/playbooks/app-state-grab.ps1` into the data root and collected, and the ui-mock replayed them (`IGNEUM_MOCK_STATE`, `IGNEUM_MOCK_LIVE`) through the real 0.3.16 UI on the Mac: `docs/plans/miner-ui-4/pc2-0.3.16/pc2-overview-light-1440.png`, `pc2-cards-light-1440.png`, `pc2-overview-dark-1440.png`, captioned "rendered from PC 2's live state at 23:33 UK" (DESKTOP-KMCV30N, RTX 5090 at 103 MH/s and 317 W, node synced, 5 peers). Handed to the site lane. | `tools/ui-mock/server.mjs`, `relay/playbooks/app-state-grab.ps1` |
| The fleet watts count mining and tuning cards only (PC 2's idle integrated card drew 48 W and was summed); the Ember off-state line no longer promises a start. | `ui/app.js` |
Tests: UI 39; the app crate on the box 152 passed (the N4 pair, the shot path, the four live tests among them).
## 9. The rebased fold (7 October 2026, 23:20 UK; renumbered: tonight's 0.3.17 is a node-only hotfix on the 0.3.16 app tree, and this branch at afc331bd ships as 0.3.18 on release-0.3.18)
- Rebased onto a9eb58f1 (origin/release-0.3.15). ui/ comes wholly from this branch; Ember's finality-paused hunk (ember-tune 1357d280) is folded in as `View.finalityWords(f)`: while finality is paused the node sub-line and the lock row say "paused" and nothing calls a lock final. Test in view.test.mjs (41 UI tests).
- src/extnode.rs reads the node lane's `igneum_getNodeInfo` shape: `params` (every resolved consensus field, compared value by value against the manifest's keys), `network` (must be equal), `powEngine` ("stub" = refused on the port check, and a fault on the app's own node: node state `stub`, pill "Node fault", one error event). 168 box tests.
- src/live.rs is Ember's merged module at the merge (nothing of this branch's lives there any more).
- External node re-decided (the Mac, 7 October 2026 00:18 UK: the hand node left and the app sat on "node stopped" all night). While the app reads another node, or refused one, it checks the port every 5 s; gone for 60 s (`extnode::TAKEOVER_WAIT_S`) it starts its own node on the freed ports and says so in Activity; a node back inside the wait is kept. api/state node.mode own | external | none with node.mode_reason, shown under the Node details. Test `an_external_node_that_goes_away_hands_the_ports_to_the_app_after_the_wait` (169 box tests).
## 10. The merge check (7 October 2026, 03:20 UK; 74c12665, on the 0.3.18 list after afc331bd)
- Design note from the node lane's 30-s propagation reading: a node behind a slow link keeps mining its own chain and the N4 "synced" test cannot see it. New `src/merge.rs` (pure): `not_merging(MergeCheck)` is true when every sink the network reports is more than the merge depth ahead of ours (the node's `params.merge_depth` via igneum_getNodeInfo, else 3,600) and none of our blocks has appeared in the network's view for 120 s, after at least 120 s of mining. Known-failed test first (`a_node_whose_blocks_never_merge_reads_behind_and_holds_the_miner`).
- Engine: every 30 s while mining, the observer's /api/live (600-s window, this machine's lanes marked) gives the network's sink and the newest block of ours it shows (`merge::view_of`); the watch line's decision runs after sync_decision_v2; the state reads "behind" with sync_cause "not merging" and the message "behind: your blocks are not merging into the network"; the miner is held through the existing synced gate; one error event with the gap in blocks. Source owed: peers' own sinks (igneum_getNodeInfo over the peer list) when the node exposes them; the observer is the first view.
- UI: nodeWords' behind case names the cause. 42 UI tests, 172 box tests. Nothing of this on the Mac tonight.
- Decided (main, 7 October 2026, 05:00 UK): `igneum_getPeers` stays on 0.3.19; the observer-based merge check ships in 0.3.18. The Mac check when it takes 0.3.18: the hands moved to the build box at 00:18 UK, so hand node 1 no longer holds 26610; the app, left in external mode, reads "node stopped" and starts its own node only on relaunch (the apply). Check after the apply: own node on 26610/26611, api/state node.mode own, digest_source rpc, synced; the miner stays paused. Read-only; nothing else that night.
- Node side landed: 6e4ace3f on build/ca3-v4-0318 (04:53 UK) adds `blockrate: { bps, ghostdagK, mergeDepth, finalityDepth, pruningDepth }` to igneum_getNodeInfo (devnet: bps 1, ghostdagK 18, mergeDepth 3600). The app reads blockrate.mergeDepth (b7d33e8e) and keeps the 3,600 fallback, since whether 6e4ace3f rides in 0.3.18 is the shipper's call. `igneum_getPeers` (lastDeliveredBlueScore) stays on 0.3.19.
## 11. Workers after an OTA relaunch (7 October 2026, 05:20 UK; 0.3.18)
- Known-failed case: PC 1 at the 0.3.17 relaunch (04:51:43Z, docs/plans/release-0.3.17.md PC 1 row): the app started its workers while the node was still syncing; the card watchdog clocked the silence anyway (a miner with no template prints no status), restarted each once, then faulted every card "no status line from the miner for 90 s (restarted once already)", pid 0, 0.0 MH/s on; faulted cards were never restarted, including at the synced transition.
- Fix (src/watchdog.rs, src/engine.rs): a miner is judged only while the node is synced (the start, no-status and zero-rate clocks hold at now while it is not); a worker silent from its start with the node synced is one restart then a fault at 60 s (`START_S`), 90 s stays for a running one; a fault the node's readiness explains (`faulted_by_node`) is released by `Event::NodeSynced` when the node syncs, the card starts again with an Activity line; one Activity line per card at its first worker start. Test `a_worker_started_while_the_node_syncs_is_not_faulted_and_a_faulted_one_returns_at_sync` (recorded state), 173 box tests.
- The pre-apply card set already persists (cards' enabled flags live in settings and the relaunch planned every enabled card); the fault, not the plan, was the failure.
- Read-back changed the reading (relaunch-engine-and-workers.txt, 05:40 UK): PC 1's node reported synced=true from the first second; the miners started at +14, +25 and +38 s, subscribed, then every template fetch timed out at 5 s for 180 s while the node ran its finality catch-up (checkpoints 8053 to 8055 locking), so no status line was printed; the watchdog restarted each at 90 s and faulted each at 180 s. Rule added: "template fetch timed out" is the miner's heartbeat (`Event::TemplateTimeout`): the silence clocks start over from the line, the card reads "waiting for the node to answer block templates", one Activity line per episode, and the miner is judged again from its last line (60 s of nothing at all after it is still a restart). The synced-gate half stays. Test extended with the recorded sequence; 173 box tests. The node-side half (the template RPC blocking under the finality replay after a restart) is with the node lane.
- Renumbered again (main, 7 October 2026, 09:2x UK): 0.3.18 is an app-only cut on the 0.3.17 tree (the clock sample gated on igneum_getExecStatus, ledger N7, commit 0e6c1248); this tree ships as 0.3.19 (release-0.3.19, node release-0.3.19-node); decimals is 0.3.20. 0e6c1248's update.rs hunk is carried here so the branch is whole; the Mac check comes on the 0.3.19 apply.

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

View file

@ -0,0 +1,29 @@
# Igneum playbook: two light-mode screenshots of the installed app (Overview, Cards) for the site's showcase card.
# Read-only: GETs the app's own URL and runs Edge headless, which renders prefers-color-scheme light by default.
# Never pauses, resumes or quits anything (the standing rule of 5 October 2026); the miners never notice.
# Queue: packaging/ota/publish-jobs.sh add --kind run --target 1ccfe586 --script relay/playbooks/app-shot-light.ps1 --title "app light-mode shots"
$ErrorActionPreference = 'Continue'
$urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url'
if (-not (Test-Path $urlFile)) { Write-Host "ERROR: $urlFile missing (is the app running?)"; exit 2 }
$url = (Get-Content $urlFile -Raw).Trim()
$edge = @("$env:ProgramFiles (x86)\Microsoft\Edge\Application\msedge.exe", "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe") | Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $edge) { Write-Host "ERROR: Edge not found"; exit 2 }
Write-Host ("edge " + (Get-Item $edge).VersionInfo.ProductVersion + " at " + $edge)
$out = Join-Path $env:TEMP 'igneum-shots'; New-Item -ItemType Directory -Force -Path $out | Out-Null
# a profile of its own, or the launcher hands the request to the user's running Edge and returns at once with no file
# (run-20261006-222112 on PC 2: both shots missing in 0 s); -Wait holds until the headless browser exits
$profile = Join-Path $out 'profile'; New-Item -ItemType Directory -Force -Path $profile | Out-Null
foreach ($page in @('overview', 'cards')) {
$png = Join-Path $out "igneum-$page-light-1440.png"
if (Test-Path $png) { Remove-Item $png -Force }
# take 3: the old headless mode (the new one wrote nothing on PC 2's Edge, exit 0 in 11 s); the screenshot path is
# passed bare, and the working directory is the output folder because old headless falls back to screenshot.png there
$args = @('--headless', '--disable-gpu', '--hide-scrollbars', '--no-first-run', '--no-sandbox', '--window-size=1440,1000', '--virtual-time-budget=8000', "--user-data-dir=$profile", "--screenshot=$png", "$url?page=$page&visible=1")
$errLog = Join-Path $out "edge-$page.log"
$p = Start-Process -FilePath $edge -ArgumentList $args -Wait -PassThru -WindowStyle Hidden -WorkingDirectory $out -RedirectStandardError $errLog
$tries = 0; while (-not (Test-Path $png) -and $tries -lt 20) { Start-Sleep -Milliseconds 500; $tries++ }
if (-not (Test-Path $png) -and (Test-Path (Join-Path $out 'screenshot.png'))) { Move-Item (Join-Path $out 'screenshot.png') $png -Force }
if (Test-Path $errLog) { Get-Content $errLog | Select-Object -First 5 | ForEach-Object { Write-Host "edge: $_" } }
if (Test-Path $png) { Write-Host "RESULT shot=$page bytes=$((Get-Item $png).Length) edge_exit=$($p.ExitCode)"; & $env:RELAY_SEND -TaskId ([long]$env:RELAY_TASK_ID) -Title "app $page light" $png } else { Write-Host "RESULT shot=$page missing edge_exit=$($p.ExitCode)" }
}
exit 0

View file

@ -0,0 +1,17 @@
# Igneum playbook: a screenshot of the installed app's window from the machine itself (Miner UI 4 (b), 0.3.17):
# POST api/shot asks the engine, the window host writes a PNG of what it shows into <data root>\shots\, and a collect
# job's glob "shots/*.png" brings it back. Read-only: never pauses, resumes or quits anything (the rule of 5 Oct 2026).
# Needs an app at 0.3.17 or later (the route and the host's SHOT line); an older app answers 400 and this exits 3.
# Queue: packaging/ota/publish-jobs.sh add --kind run --target 1ccfe586 --script relay/playbooks/app-shot.ps1 --title "app shot"
# then packaging/ota/publish-jobs.sh add --kind collect --target 1ccfe586 --glob "shots/*.png"
$ErrorActionPreference = 'Continue'
$urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url'
if (-not (Test-Path $urlFile)) { Write-Host "ERROR: $urlFile missing (is the app running?)"; exit 2 }
$url = (Get-Content $urlFile -Raw).Trim()
$name = if ($env:SHOT_NAME) { $env:SHOT_NAME } else { 'window' }
try { $r = Invoke-RestMethod -Method Post -Uri ($url + 'api/shot') -ContentType 'application/json' -Body (@{ name = $name } | ConvertTo-Json) -TimeoutSec 15 }
catch { Write-Host "RESULT shot=$name failed: $($_.Exception.Message) (the app needs 0.3.17 or later)"; exit 3 }
$path = $r.path
$tries = 0; while (-not (Test-Path $path) -and $tries -lt 30) { Start-Sleep -Milliseconds 500; $tries++ }
if (Test-Path $path) { Write-Host "RESULT shot=$name path=$path bytes=$((Get-Item $path).Length)"; exit 0 }
Write-Host "RESULT shot=$name missing after 15 s (path $path); the window host did not write it"; exit 4

View file

@ -0,0 +1,18 @@
# Igneum playbook: copy the installed app's live state and chain feed (two GETs) back through the relay, so a capture
# of the dashboard can be rendered elsewhere from this machine's real numbers. Read-only: never pauses, resumes or
# quits anything (the standing rule of 5 October 2026).
# Queue: packaging/ota/publish-jobs.sh add --kind run --target 1ccfe586 --script relay/playbooks/app-state-grab.ps1 --title "app state grab"
# then packaging/ota/publish-jobs.sh add --kind collect --target 1ccfe586 --glob "state-grab/*.json"
$ErrorActionPreference = 'Continue'
$urlFile = Join-Path $env:LOCALAPPDATA 'igneum\app\app.url'
if (-not (Test-Path $urlFile)) { Write-Host "ERROR: $urlFile missing (is the app running?)"; exit 2 }
$url = (Get-Content $urlFile -Raw).Trim()
# into the app's data root, where a collect job's glob "state-grab/*.json" picks the files up (the relay's send.ps1 is
# not installed on every PC: PC 2, 6 October 2026)
$out = Join-Path $env:LOCALAPPDATA 'igneum\state-grab'; New-Item -ItemType Directory -Force -Path $out | Out-Null
foreach ($pair in @(@('api/state', 'state.json'), @('api/live?window=120', 'live.json'))) {
$dest = Join-Path $out $pair[1]
try { Invoke-WebRequest -UseBasicParsing -Uri ($url + $pair[0]) -TimeoutSec 15 -OutFile $dest; Write-Host "RESULT file=$($pair[1]) bytes=$((Get-Item $dest).Length)"; if ($env:RELAY_SEND -and (Test-Path $env:RELAY_SEND)) { & $env:RELAY_SEND -TaskId ([long]$env:RELAY_TASK_ID) -Title ("app " + $pair[1]) $dest } }
catch { Write-Host "RESULT file=$($pair[1]) failed: $($_.Exception.Message)" }
}
exit 0

View file

@ -31,7 +31,10 @@ const coin = path.resolve(here, '../../brand/igneum-coin-1024.png');
const port = parseInt(process.argv[2] || '4310', 10);
const PREFIX = '/t/mock';
const recState = JSON.parse(fs.readFileSync(path.join(here, 'recorded/state.json'), 'utf8'));
// IGNEUM_MOCK_STATE=<file> replays a state captured from a real machine (relay/playbooks/app-state-grab.ps1) instead of
// the recorded one; IGNEUM_MOCK_LIVE=<file> serves that machine's /api/live reply as it was (timestamps shifted to now)
const recState = JSON.parse(fs.readFileSync(process.env.IGNEUM_MOCK_STATE || path.join(here, 'recorded/state.json'), 'utf8'));
const recLive = process.env.IGNEUM_MOCK_LIVE ? JSON.parse(fs.readFileSync(process.env.IGNEUM_MOCK_LIVE, 'utf8')) : null;
const recLog = JSON.parse(fs.readFileSync(path.join(here, 'recorded/log.json'), 'utf8')).lines;
const t0 = Date.now() / 1000; // server start
const shift = t0 - recState.now; // recorded clock -> live clock
@ -116,6 +119,8 @@ function stateNow() {
s.program.eta_s = Math.max(0, Math.round(3600 - ((t - t0) % 3600)));
s.node.blocks = recState.node.blocks + Math.floor(t - t0); s.node.headers = s.node.blocks; s.node.daa = s.node.blocks;
s.node.last_reading_age_s = (t - t0) % 10;
// a captured state (IGNEUM_MOCK_STATE) is replayed as it was: the machine's own name, cards, rate and numbers
if (process.env.IGNEUM_MOCK_STATE) { s.node.blocks = recState.node.blocks; s.node.headers = recState.node.headers; s.node.daa = recState.node.daa; return s; }
// a live-looking hash rate
const wob = Math.sin((t - t0) / 7) * 1.4;
s.mining.hash_total = +(16.9 + wob).toFixed(2); s.mining.cards[0].hash_now = s.mining.hash_total;
@ -180,7 +185,14 @@ http.createServer((req, res) => {
return send(res, 200, types['.html'], fs.readFileSync(path.join(uiDir, 'index.html')));
}
if (rest === '/api/state' && req.method === 'GET') return json(res, stateNow());
if (rest === '/api/live' && req.method === 'GET') return json(res, liveNow(parseInt(u.searchParams.get('window') || '120', 10)));
if (rest === '/api/live' && req.method === 'GET') {
if (recLive && recLive.blocks) {
// replayed with its newest block 3 s behind now, so the module's window shows it as it was captured
const newest = Math.max(...recLive.blocks.map(b => b.ts || 0)), dt = Date.now() - 3000 - newest;
const d = JSON.parse(JSON.stringify(recLive)); d.blocks.forEach(b => { b.ts = (b.ts || newest) + dt; if (!b.miner) b.miner = 'unknown'; }); d.now = Math.floor(Date.now() / 1000); return json(res, d);
}
return json(res, liveNow(parseInt(u.searchParams.get('window') || '120', 10)));
}
if (rest === '/api/log' && req.method === 'GET') {
const after = parseInt(u.searchParams.get('after') || '0', 10), limit = Math.min(20000, parseInt(u.searchParams.get('limit') || '400', 10));
let out = log.filter(l => l.seq > after); if (out.length > limit) out = out.slice(out.length - limit);