Files
Rick Morgans 2356a63aea CLI: run takes --param-check (#6921) (#6924)
* claim: #6921

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CLI: run refuses unknown options and takes --param-check (#6921)

`solidcraft-cli run` now checks its options through `known_args`, so a
mistyped option fails instead of being ignored, and takes
`--param-check off|warn|strict` with the MCP server's words and default
`off`. `warn` prints each unknown key with a suggestion on stderr and
runs the step; `strict` stops at that step and writes nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CLI: run's option check takes --param-check; warn on timed runs as steps complete

`run` keeps #6919's (#6918) unknown-option check, with `--param-check`
among the options that take a value. `Session::run_script_timed` now
takes the same per-step callback as `run_script_each`, so `run --timings`
or `--profile` at the warn level reports a step's warnings even when a
later step fails, instead of after the script (and not at all on a
failure).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:40:30 +00:00

152 lines
7.7 KiB
Rust

//! Reproducible evidence through the production command/job interfaces.
//! Usage: studio_evidence RECIPE.json RENDER-PARAMS.json OUTPUT.png
//! Writes the editable design, command timings and actual adapter/job metadata beside PNG.
//! Accepts runtime requests as well as inline or named saved render jobs.
use serde_json::{Value, json};
use solidcraft_engine::{
Session,
doc::render_settings::{RenderJob, RenderSetup},
render::job::{CpuHost, ErrorCode, JobId, Phase},
};
use std::{
io::Read,
path::Path,
time::{Duration, Instant},
};
fn read_json(path: &str) -> Result<Value, String> {
let mut bytes = Vec::new();
std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?.take(16_777_217).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
if bytes.len() > 16_777_216 {
return Err("evidence input exceeds 16 MiB".into());
}
serde_json::from_slice(&bytes).map_err(|e| format!("{path}: {e}"))
}
// Only decide which optional host must be installed. The production command
// still validates and resolves the actual request without a second wire format.
fn needs_gpu(session: &Session, params: &Value) -> Result<bool, String> {
let inline = params.get("settings").filter(|value| !value.is_null());
let named = params.get("setup").filter(|value| !value.is_null());
if inline.is_some() && named.is_some() {
return Err("choose either setup or settings".into());
}
let inline_setup = inline
.filter(|value| value.get("job").is_some_and(|job| !job.is_null()))
.map(|value| serde_json::from_value::<RenderSetup>(value.clone()).map_err(|e| e.to_string()))
.transpose()?;
let setup = if let Some(name) = named.and_then(Value::as_str) {
Some(session.doc.render.setups.get(name).ok_or("no saved render setup with that name")?)
} else {
inline_setup.as_ref()
};
if let Some(setup) = setup
&& let Some(job) = &setup.job
{
if params.as_object().is_some_and(|object| object.keys().any(|key| !matches!(key.as_str(), "setup" | "settings" | "replace"))) {
return Err("saved render jobs cannot be mixed with runtime controls".into());
}
setup.validate().map_err(|e| e.to_string())?;
return Ok(matches!(job, RenderJob::GpuPath { .. }));
}
Ok(params.get("progressive").and_then(Value::as_bool) == Some(true))
}
fn run() -> Result<(), String> {
let args: Vec<String> = std::env::args().skip(1).collect();
let [recipe_path, params_path, output_path] = args.as_slice() else {
return Err("usage: studio_evidence RECIPE.json RENDER-PARAMS.json OUTPUT.png".into());
};
let output = Path::new(output_path);
if !output.extension().is_some_and(|e| e.eq_ignore_ascii_case("png")) {
return Err("output must have a .png extension".into());
}
let recipe = read_json(recipe_path)?;
let params = read_json(params_path)?;
let mut session = Session::default();
eprintln!("Generating original CAD scene from {recipe_path}");
let generation_start = Instant::now();
let (commands, command_timings) = session.run_script_timed(&recipe, false, &mut |_, _| {}).map_err(|e| e.to_string())?;
let generation_seconds = generation_start.elapsed().as_secs_f64();
let design_path = output.with_extension(solidcraft_engine::io::DESIGN_EXT);
session.execute("file.save_as", &json!({"path":design_path})).map_err(|e| e.to_string())?;
let scene = solidcraft_engine::product_scene::extract(&session).map_err(|e| e.to_string())?;
let bodies: Vec<Value> = scene
.meshes
.iter()
.map(|mesh| json!({"name":mesh.name,"vertices":mesh.mesh.positions.len(),"triangles":mesh.mesh.triangles.len()}))
.collect();
drop(scene);
let cpu_host_start = Instant::now();
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
let cpu_host_seconds = cpu_host_start.elapsed().as_secs_f64();
// Match runtime and saved-job dispatch. CPU evidence must not require a
// Vulkan device, parse GPU policy or initialize a GPU host.
let (adapter_report, gpu_host_seconds) = if needs_gpu(&session, &params)? {
let gpu_host_start = Instant::now();
let policy = solidcraft_gpu::select::Policy::from_env().map_err(|e| e.to_string())?;
let (adapter, report) =
solidcraft_gpu::select::pick_path(policy, |_| true).ok_or("no Vulkan or Metal adapter satisfies the requested GPU policy")?;
session.register_render_job_host(solidcraft_gpu::path::host_on_adapter(&adapter).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
(Some(report), Some(gpu_host_start.elapsed().as_secs_f64()))
} else {
(None, None)
};
let preflight = session.execute("render.preflight", &params).map_err(|e| e.to_string())?;
let start = Instant::now();
let started = session.execute("render.start", &params).map_err(|e| e.to_string())?;
let executor = started
.get("resolved")
.and_then(|resolved| resolved.get("executor"))
.and_then(Value::as_str)
.ok_or("started job did not report the resolved image executor")?
.to_owned();
let id: JobId = serde_json::from_value(started.get("job").cloned().ok_or("missing job id")?).map_err(|e| e.to_string())?;
let handle = session.render_job_handle(id).map_err(|e| e.to_string())?;
eprintln!("Rendering through production {executor} executor");
if let Some(adapter) = &adapter_report {
eprintln!("GPU adapter: {adapter:?}");
}
let status = loop {
match handle.wait_timeout(Duration::from_secs(10)) {
Ok(status) => break status,
Err(error) if error.code == ErrorCode::TimedOut && start.elapsed() < Duration::from_secs(3600) => {
let status = handle.status().map_err(|e| e.to_string())?;
eprintln!(
"Render progress: {:?} {}/{} after {:.1}s",
status.progress.phase,
status.progress.done,
status.progress.total,
start.elapsed().as_secs_f64()
);
}
Err(error) => {
let _ = handle.cancel();
return Err(error.to_string());
}
}
};
let render_and_encode_seconds = start.elapsed().as_secs_f64();
if status.progress.phase != Phase::Completed {
return Err(status.error.map_or_else(|| "render did not complete".into(), |e| e.to_string()));
}
let exported = session.execute("render.export", &json!({"job":id,"path":output_path,"overwrite":true})).map_err(|e| e.to_string())?;
let report = json!({
"recipe":recipe_path,"render_params":params,"executor":executor,"adapter":adapter_report,
"environment":{"SOLIDCRAFT_GPU":std::env::var("SOLIDCRAFT_GPU").ok(),"WGPU_BACKEND":std::env::var("WGPU_BACKEND").ok()},
"cad_generation_seconds":generation_seconds,"cpu_host_setup_seconds":cpu_host_seconds,"gpu_host_setup_seconds":gpu_host_seconds,
"render_and_encode_seconds":render_and_encode_seconds,
"timing_scope":"wall time from render.start through completed PNG encoding; includes job preparation and readback, excludes CAD generation, preflight and initial CPU/GPU host setup; GPU setup timing includes adapter selection and pipelines when requested",
"bodies":bodies,"commands":commands,"command_timings":command_timings,
"preflight":preflight,"export":exported,"editable_design":design_path
});
std::fs::write(output.with_extension("json"), serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
println!("{}", serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?);
Ok(())
}
fn main() -> Result<(), String> {
run()
}