286 lines
11 KiB
Rust
286 lines
11 KiB
Rust
//! Replay loader — reconstruct a runnable spec from an on-disk
|
|
//! bundle. Used by `simulation::run_to_tempdir` when the caller
|
|
//! passes a spec text that names `[replay] bundle = <path>`.
|
|
//!
|
|
//! Two reconstruction paths (SPEC §7.1):
|
|
//!
|
|
//! 1. **`sim/spec.toml` present.** The bundle is sim-origin and the
|
|
//! spec text was preserved byte-for-byte; the replay just re-runs
|
|
//! the engine against that text. Self-replay byte-equality
|
|
//! follows from the engine's determinism (TESTING_SPEC §3.1).
|
|
//!
|
|
//! 2. **Prod-shape (no `sim/spec.toml`).** Walk the bundle, derive
|
|
//! `(run_id, duration_ms)` from MANIFEST.json, walk per-host
|
|
//! directories for boot / finalize / restart wall_ms's, and
|
|
//! pull mutation triples (kind, at_ms) from the orchestrator's
|
|
//! `Custom` event stream. Links are not recoverable from
|
|
//! prod-shape and stay empty; the parity diff treats absent
|
|
//! links as parameter-unknown (SPEC §7.1).
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use crate::sim_backend::bundle as fs_io;
|
|
use crate::spec::{self, validate_path_component, Host, Mutation, MutationKind, ParsedSpec};
|
|
use crate::SimError;
|
|
|
|
/// Load a [`ParsedSpec`] for a replay run. `bundle_path` is the
|
|
/// bundle root; `seed` is the run seed the engine should use.
|
|
pub fn load_replay_spec(bundle_path: &Path) -> Result<ParsedSpec, SimError> {
|
|
if !bundle_path.is_dir() {
|
|
return Err(SimError::SpecParse(format!(
|
|
"replay bundle not found at {}",
|
|
bundle_path.display()
|
|
)));
|
|
}
|
|
let spec_toml = bundle_path.join("sim/spec.toml");
|
|
if spec_toml.is_file() {
|
|
let text = fs_io::read_text(&spec_toml).map_err(SimError::Io)?;
|
|
return spec::parse(&text);
|
|
}
|
|
reconstruct_from_prod_shape(bundle_path)
|
|
}
|
|
|
|
fn reconstruct_from_prod_shape(bundle_path: &Path) -> Result<ParsedSpec, SimError> {
|
|
let manifest_path = bundle_path.join("MANIFEST.json");
|
|
let manifest_text = fs_io::read_text(&manifest_path).map_err(SimError::Io)?;
|
|
let manifest: serde_json::Value = serde_json::from_str(&manifest_text)
|
|
.map_err(|e| SimError::SpecParse(format!("MANIFEST.json: {e}")))?;
|
|
let run_id = manifest
|
|
.get("run_id")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| SimError::SpecParse("MANIFEST.json missing run_id".into()))?
|
|
.to_string();
|
|
validate_path_component(&run_id, "MANIFEST.run_id")?;
|
|
let duration_ms = manifest
|
|
.get("duration_ms")
|
|
.and_then(|v| v.as_u64())
|
|
.ok_or_else(|| SimError::SpecParse("MANIFEST.json missing duration_ms".into()))?;
|
|
|
|
let mut hosts: Vec<Host> = Vec::new();
|
|
let subdirs = fs_io::list_subdirs(bundle_path).map_err(SimError::Io)?;
|
|
for name in subdirs {
|
|
if name == "sim" || name == "detector" {
|
|
continue;
|
|
}
|
|
validate_path_component(&name, "bundle host directory")?;
|
|
let host_dir = bundle_path.join(&name);
|
|
let host = build_host(&name, &host_dir)?;
|
|
hosts.push(host);
|
|
}
|
|
hosts.sort_by(|a, b| a.name.cmp(&b.name));
|
|
|
|
let mutations = collect_mutations(bundle_path)?;
|
|
|
|
// Synthesize a spec text so the bundle that the replay writes
|
|
// back to `sim/spec.toml` reflects what the engine actually ran.
|
|
// The text is deterministic in the reconstructed fields.
|
|
let source_text = synthesize_spec_text(&run_id, duration_ms, &hosts, &mutations);
|
|
Ok(ParsedSpec {
|
|
source_text,
|
|
run_id,
|
|
duration_ms,
|
|
host_defaults: Default::default(),
|
|
hosts,
|
|
links: Vec::new(),
|
|
mutations,
|
|
})
|
|
}
|
|
|
|
fn build_host(name: &str, host_dir: &Path) -> Result<Host, SimError> {
|
|
// Initial boot.
|
|
let boot_path = host_dir.join("boot.json");
|
|
let boot_text = fs_io::read_text(&boot_path).map_err(SimError::Io)?;
|
|
let boot: serde_json::Value = serde_json::from_str(&boot_text)
|
|
.map_err(|e| SimError::SpecParse(format!("{}: {e}", boot_path.display())))?;
|
|
let start_at_ms = boot
|
|
.get("wall_ms")
|
|
.and_then(|v| v.as_u64())
|
|
.unwrap_or(0);
|
|
let stage_index = boot
|
|
.get("stage_index")
|
|
.and_then(|v| v.as_u64())
|
|
.map(|v| v as u32);
|
|
|
|
// Subsequent epochs from boot-NNN.json files.
|
|
let mut restart_at_ms: Vec<u64> = Vec::new();
|
|
for path in fs_io::list_files_with_prefix(host_dir, "boot-")
|
|
.map_err(SimError::Io)?
|
|
{
|
|
let text = fs_io::read_text(&path).map_err(SimError::Io)?;
|
|
let v: serde_json::Value = serde_json::from_str(&text)
|
|
.map_err(|e| SimError::SpecParse(format!("{}: {e}", path.display())))?;
|
|
if let Some(w) = v.get("wall_ms").and_then(|x| x.as_u64()) {
|
|
restart_at_ms.push(w);
|
|
}
|
|
}
|
|
restart_at_ms.sort();
|
|
|
|
// Last finalize.
|
|
let mut finalize_paths: Vec<PathBuf> = Vec::new();
|
|
let direct = host_dir.join("finalize.json");
|
|
if direct.is_file() {
|
|
finalize_paths.push(direct);
|
|
}
|
|
for path in fs_io::list_files_with_prefix(host_dir, "finalize-")
|
|
.map_err(SimError::Io)?
|
|
{
|
|
finalize_paths.push(path);
|
|
}
|
|
let mut stop_at_ms: Option<u64> = None;
|
|
if let Some(last) = finalize_paths.last() {
|
|
let text = fs_io::read_text(last).map_err(SimError::Io)?;
|
|
let v: serde_json::Value = serde_json::from_str(&text)
|
|
.map_err(|e| SimError::SpecParse(format!("{}: {e}", last.display())))?;
|
|
let reason = v
|
|
.get("shutdown_reason")
|
|
.and_then(|x| x.as_str())
|
|
.unwrap_or("");
|
|
// Only "clean" gets propagated as a spec-declared stop_at_ms.
|
|
// "end_of_run" finalizes synthesize when the host was alive
|
|
// at duration_ms; they get re-emitted automatically when the
|
|
// replay's engine reaches duration_ms. "restart" finalizes
|
|
// are intermediate.
|
|
if reason == "clean" {
|
|
stop_at_ms = v.get("wall_ms").and_then(|x| x.as_u64());
|
|
}
|
|
}
|
|
|
|
Ok(Host {
|
|
name: name.to_string(),
|
|
role: name.to_string(),
|
|
stage_index,
|
|
start_at_ms,
|
|
restart_at_ms,
|
|
stop_at_ms,
|
|
crash_at_ms: None,
|
|
})
|
|
}
|
|
|
|
fn collect_mutations(bundle_path: &Path) -> Result<Vec<Mutation>, SimError> {
|
|
// The mutation schedule is reconstructed from the orchestrator's
|
|
// `Custom` event stream (SPEC §7.1). Fall back to scanning every
|
|
// node if no `orchestrator` directory exists — the corpus's host
|
|
// ordering is fixed, but a generic replay-target may not have
|
|
// one. We pick the alphabetically-first host that has events.
|
|
let subdirs = fs_io::list_subdirs(bundle_path).map_err(SimError::Io)?;
|
|
let mut chosen: Option<PathBuf> = None;
|
|
for name in &subdirs {
|
|
if name == "sim" {
|
|
continue;
|
|
}
|
|
let events_dir = bundle_path.join(name).join("events");
|
|
if events_dir.is_dir() {
|
|
chosen = Some(events_dir);
|
|
break;
|
|
}
|
|
}
|
|
let Some(events_dir) = chosen else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let paths = fs_io::list_files_with_prefix(&events_dir, "")
|
|
.map_err(SimError::Io)?;
|
|
let mut mutations: Vec<Mutation> = Vec::new();
|
|
for path in paths {
|
|
let text = fs_io::read_text(&path).map_err(SimError::Io)?;
|
|
let env: serde_json::Value = serde_json::from_str(&text)
|
|
.map_err(|e| SimError::SpecParse(format!("{}: {e}", path.display())))?;
|
|
let Some(arr) = env.get("records").and_then(|v| v.as_array()) else {
|
|
continue;
|
|
};
|
|
for rec in arr {
|
|
let variant = rec.get("variant").and_then(|v| v.as_str()).unwrap_or("");
|
|
if variant != "Custom" {
|
|
continue;
|
|
}
|
|
let user_kind = rec
|
|
.get("user_kind")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.to_string();
|
|
let at_ms = rec.get("wall_ms").and_then(|v| v.as_u64()).unwrap_or(0);
|
|
let kind = match user_kind.as_str() {
|
|
"partition" => MutationKind::Partition { edges: Vec::new() },
|
|
"heal" => MutationKind::Heal { edges: Vec::new() },
|
|
"restart" => MutationKind::Restart {
|
|
node: String::new(),
|
|
},
|
|
"clock_jump" => MutationKind::ClockJump {
|
|
node: String::new(),
|
|
delta_ms: 0,
|
|
},
|
|
"link_change" => MutationKind::LinkChange,
|
|
_ => continue,
|
|
};
|
|
mutations.push(Mutation {
|
|
at_ms,
|
|
spec_index: mutations.len(),
|
|
kind,
|
|
});
|
|
}
|
|
}
|
|
Ok(mutations)
|
|
}
|
|
|
|
fn synthesize_spec_text(
|
|
run_id: &str,
|
|
duration_ms: u64,
|
|
hosts: &[Host],
|
|
mutations: &[Mutation],
|
|
) -> String {
|
|
// Emit a TOML representation of what we recovered. Used purely
|
|
// as the new bundle's `sim/spec.toml`; the engine does not
|
|
// re-parse from this text (it has the already-built ParsedSpec).
|
|
let mut out = String::new();
|
|
out.push_str(&format!("run_id = {}\n", toml_string(run_id)));
|
|
out.push_str(&format!("duration_ms = {duration_ms}\n"));
|
|
out.push('\n');
|
|
for h in hosts {
|
|
out.push_str("[[hosts]]\n");
|
|
out.push_str(&format!("name = {}\n", toml_string(&h.name)));
|
|
out.push_str(&format!("role = {}\n", toml_string(&h.role)));
|
|
if let Some(idx) = h.stage_index {
|
|
out.push_str(&format!("stage_index = {idx}\n"));
|
|
}
|
|
out.push_str(&format!("start_at_ms = {}\n", h.start_at_ms));
|
|
if !h.restart_at_ms.is_empty() {
|
|
out.push_str(&format!("restart_at_ms = {:?}\n", h.restart_at_ms));
|
|
}
|
|
if let Some(stop) = h.stop_at_ms {
|
|
out.push_str(&format!("stop_at_ms = {stop}\n"));
|
|
}
|
|
out.push('\n');
|
|
}
|
|
for m in mutations {
|
|
out.push_str("[[mutations]]\n");
|
|
out.push_str(&format!("at_ms = {}\n", m.at_ms));
|
|
match &m.kind {
|
|
MutationKind::Partition { .. } => out.push_str("kind = \"partition\"\n"),
|
|
MutationKind::Heal { .. } => out.push_str("kind = \"heal\"\n"),
|
|
MutationKind::Restart { .. } => out.push_str("kind = \"restart\"\n"),
|
|
MutationKind::ClockJump { .. } => out.push_str("kind = \"clock_jump\"\n"),
|
|
MutationKind::LinkChange => out.push_str("kind = \"link_change\"\n"),
|
|
}
|
|
out.push('\n');
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Render `s` as a TOML basic string. Escapes `\\`, `"`, and control
|
|
/// characters so the result round-trips through any TOML parser.
|
|
fn toml_string(s: &str) -> String {
|
|
let mut out = String::with_capacity(s.len() + 2);
|
|
out.push('"');
|
|
for ch in s.chars() {
|
|
match ch {
|
|
'\\' => out.push_str("\\\\"),
|
|
'"' => out.push_str("\\\""),
|
|
'\n' => out.push_str("\\n"),
|
|
'\r' => out.push_str("\\r"),
|
|
'\t' => out.push_str("\\t"),
|
|
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
|
c => out.push(c),
|
|
}
|
|
}
|
|
out.push('"');
|
|
out
|
|
}
|