use super::client::{PlmClient, PlmError};
use brep_render::engine_state::EngineState;
use serde::Deserialize;
use serde_json::{json, Value};
#[derive(Debug, Clone, PartialEq)]
pub struct Bake {
pub report: Value,
pub solids: usize,
pub thumbnail: Option<Vec<u8>>,
}
pub fn bake_document(document: &str) -> Result<Bake, String> {
let mut engine = EngineState::new();
engine.set_history_json(document)?;
while engine.run_pending() {
engine.pump();
}
let report = serde_json::from_str(&engine.history_report_json()).unwrap_or(Value::Null);
let solids = engine.scene.solids().len();
let thumbnail = brep_render::thumbnail::render_png(&brep_render::thumbnail::capture(&engine.scene), brep_render::thumbnail::SIZE);
Ok(Bake { report, solids, thumbnail })
}
pub fn verdict(number: &str, revision: &str, bake: &Bake) -> Result<(), String> {
let errors: Vec<&str> = bake.report["featureErrors"]
.as_array()
.map(|list| list.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
if errors.is_empty() {
return Ok(());
}
Err(format!("{number} revision {revision} did not build: {}", errors.join("; ")))
}
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(default)]
pub struct BakeJob {
pub expected_hash: Option<String>,
pub id: String,
pub number: String,
pub revision_label: String,
pub document_key: String,
pub source: String,
pub attempts: u32,
pub lease_expires_at: Option<u64>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum JobOutcome {
Done { job: BakeJob },
Failed { job: BakeJob, error: String },
Dropped { job: BakeJob, why: String },
}
impl JobOutcome {
pub fn job(&self) -> &BakeJob {
match self {
JobOutcome::Done { job } | JobOutcome::Failed { job, .. } | JobOutcome::Dropped { job, .. } => job,
}
}
pub fn status(&self) -> &'static str {
match self {
JobOutcome::Done { .. } => "done",
JobOutcome::Failed { .. } => "failed",
JobOutcome::Dropped { .. } => "dropped",
}
}
pub fn to_json(&self) -> Value {
let job = self.job();
let mut out = json!({
"id": job.id, "number": job.number, "revision": job.revision_label,
"source": job.source, "status": self.status(),
});
match self {
JobOutcome::Failed { error, .. } => out["error"] = json!(error),
JobOutcome::Dropped { why, .. } => out["why"] = json!(why),
JobOutcome::Done { .. } => {}
}
out
}
}
pub async fn run_pass(client: &PlmClient, max_jobs: usize) -> Result<Vec<JobOutcome>, PlmError> {
run_pass_with(client, max_jobs, None).await
}
pub async fn run_pass_with(client: &PlmClient, max_jobs: usize, renewal: Option<&Renewal>) -> Result<Vec<JobOutcome>, PlmError> {
let mut outcomes = Vec::new();
while outcomes.len() < max_jobs {
let claimed = client.call("POST", "/api/bake/next", Some(b"{}".to_vec())).await?;
if claimed.status == 204 {
break;
}
let job: BakeJob = serde_json::from_slice(&claimed.body)
.map_err(|e| PlmError::Malformed(format!("/api/bake/next: {e}")))?;
let _renewing = renewal.map(|r| r.start(&job.id));
outcomes.push(bake_job(client, job).await?);
}
Ok(outcomes)
}
async fn bake_job(client: &PlmClient, job: BakeJob) -> Result<JobOutcome, PlmError> {
let refusal = match client.get_document(&job.document_key).await {
Ok(Some(bytes)) if job.expected_hash.as_ref().is_some_and(|hash| *hash != super::thumbnail::content_hash(&String::from_utf8_lossy(&bytes))) => {
"the model changed after Force resave was queued; retry it".into()
}
Ok(Some(bytes)) => match match std::str::from_utf8(&bytes) {
Ok(text) => bake_document(&super::cached_copies::with_current_copies(client, text).await),
Err(e) => Err(e.to_string()),
} {
Ok(bake) => match verdict(&job.number, &job.revision_label, &bake) {
Ok(()) => return keep(client, job, bytes, bake.thumbnail).await,
Err(sentence) => sentence,
},
Err(why) => format!(
"{} revision {} is not a document the CAD app can open: {why}",
job.number, job.revision_label
),
},
Ok(None) => format!("{} revision {} was queued with no document to bake", job.number, job.revision_label),
Err(error @ (PlmError::Unreachable(_) | PlmError::SignIn(_) | PlmError::Forbidden(_))) => return Err(error),
Err(error) => format!("{} revision {}: its document could not be read: {error}", job.number, job.revision_label),
};
report_failed(client, job, refusal).await
}
async fn keep(client: &PlmClient, job: BakeJob, bytes: Vec<u8>, thumbnail: Option<Vec<u8>>) -> Result<JobOutcome, PlmError> {
let mut document: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|e| PlmError::Malformed(e.to_string()))?;
document["thumbnail"] = match &thumbnail {
Some(png) => brep_render::thumbnail::embedded_png(png, brep_render::thumbnail::SIZE, "ready"),
None => brep_render::thumbnail::empty_embedded("empty"),
};
let bytes = serde_json::to_vec(&document).map_err(|e| PlmError::Malformed(e.to_string()))?;
let path = format!("/api/bake/jobs/{}/result", super::identity::segment(&job.id));
let hash = super::thumbnail::content_hash(&String::from_utf8_lossy(&bytes));
match client.call("PUT", &path, Some(bytes)).await {
Ok(_) => {
if let Some(png) = thumbnail {
if let Err(why) = super::thumbnail::put(client, &job.document_key, &hash, png).await {
log::warn!("{} revision {}: baked, but its thumbnail was not kept: {why}", job.number, job.revision_label);
}
}
Ok(JobOutcome::Done { job })
}
Err(PlmError::Conflict(sentence)) if sentence.contains("checked out") => {
report_failed(client, job, sentence).await
}
Err(error @ (PlmError::Unreachable(_) | PlmError::SignIn(_) | PlmError::Forbidden(_))) => Err(error),
Err(PlmError::Conflict(sentence)) if job.source == "resave" => report_failed(client, job, sentence).await,
Err(error) => Ok(JobOutcome::Dropped { job, why: error.to_string() }),
}
}
async fn report_failed(client: &PlmClient, job: BakeJob, error: String) -> Result<JobOutcome, PlmError> {
let path = format!("/api/bake/jobs/{}/fail", super::identity::segment(&job.id));
let body = serde_json::to_vec(&json!({ "error": error })).unwrap_or_default();
match client.call("POST", &path, Some(body)).await {
Ok(_) => Ok(JobOutcome::Failed { job, error }),
Err(refused @ (PlmError::Unreachable(_) | PlmError::SignIn(_) | PlmError::Forbidden(_))) => Err(refused),
Err(refused) => Ok(JobOutcome::Dropped { job, why: format!("{error} (and the server refused the report: {refused})") }),
}
}
#[cfg(not(target_arch = "wasm32"))]
pub fn pass_blocking(url: &str, token: &str, max_jobs: usize) -> Result<Vec<JobOutcome>, String> {
let client = PlmClient::new(std::rc::Rc::new(super::transport::EhttpTransport::new(url)));
let renewal = Renewal::new(url, token, RENEW_EVERY);
block_on(async {
client.sign_in_with_token(token).await.map_err(|e| e.to_string())?;
run_pass_with(&client, max_jobs, Some(&renewal)).await.map_err(|e| e.to_string())
})
}
pub const RENEW_EVERY: std::time::Duration = std::time::Duration::from_secs(5 * 60);
#[cfg(target_arch = "wasm32")]
pub enum Renewal {}
#[cfg(target_arch = "wasm32")]
impl Renewal {
fn start(&self, _: &str) {
match *self {}
}
}
#[cfg(not(target_arch = "wasm32"))]
pub struct Renewal {
url: String,
token: String,
every: std::time::Duration,
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct RenewLog {
pub renewed: u32,
pub refused: Option<String>,
}
#[cfg(not(target_arch = "wasm32"))]
impl Renewal {
pub fn new(url: &str, token: &str, every: std::time::Duration) -> Self {
Self { url: url.trim_end_matches('/').to_string(), token: token.trim().to_string(), every }
}
pub fn start(&self, job: &str) -> Renewing {
let (stop, stopped) = std::sync::mpsc::channel::<()>();
let (url, token, every) = (format!("{}/api/bake/jobs/{}/renew", self.url, super::identity::segment(job)), self.token.clone(), self.every);
let job = job.to_string();
let handle = std::thread::spawn(move || {
let mut log = RenewLog::default();
let mut unreachable_said = false;
loop {
match stopped.recv_timeout(every) {
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
_ => return log,
}
let request = ehttp::Request {
headers: ehttp::Headers::new(&[
("Authorization", &format!("Bearer {token}")),
("Content-Type", "application/json"),
]),
..ehttp::Request::post(url.clone(), b"{}".to_vec())
};
match ehttp::fetch_blocking(&request) {
Ok(response) if response.ok => log.renewed += 1,
Ok(response) => {
let answer = super::PlmResponse { status: response.status, headers: Vec::new(), body: response.bytes };
let sentence = PlmError::from_response(&answer).to_string();
eprintln!("bake worker: job {job}: the lease renewal was refused: {sentence}");
log.refused = Some(sentence);
return log;
}
Err(why) => {
if !unreachable_said {
eprintln!("bake worker: job {job}: the lease renewal got no answer ({why}); still trying");
unreachable_said = true;
}
}
}
}
});
Renewing { stop: Some(stop), handle: Some(handle) }
}
}
#[cfg(not(target_arch = "wasm32"))]
pub struct Renewing {
stop: Option<std::sync::mpsc::Sender<()>>,
handle: Option<std::thread::JoinHandle<RenewLog>>,
}
#[cfg(not(target_arch = "wasm32"))]
impl Renewing {
pub fn finish(mut self) -> RenewLog {
self.stop.take();
self.handle.take().and_then(|h| h.join().ok()).unwrap_or_default()
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Drop for Renewing {
fn drop(&mut self) {
self.stop.take();
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn block_on<F: std::future::Future>(future: F) -> F::Output {
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
struct Unpark(std::thread::Thread);
impl Wake for Unpark {
fn wake(self: Arc<Self>) {
self.0.unpark();
}
}
let waker = Waker::from(Arc::new(Unpark(std::thread::current())));
let mut context = Context::from_waker(&waker);
let mut future = std::pin::pin!(future);
loop {
match future.as_mut().poll(&mut context) {
Poll::Ready(output) => return output,
Poll::Pending => std::thread::park(),
}
}
}
#[cfg(not(target_arch = "wasm32"))]
#[derive(Debug, Clone, PartialEq)]
pub struct WorkerArgs {
pub url: String,
pub token: String,
pub once: bool,
pub poll: u64,
}
pub const WORKER_USAGE: &str = "brep-app --bake-worker [--plm-url URL] [--plm-token-file FILE] [--once | --poll SECS]
--bake-worker run as the PLM's headless bake worker: take each queued
family member or template copy, build it, keep it or
report why it failed. One line per job. No window.
--plm-url URL the PLM server (else BREP_PLM_URL, else plm.json in the
config dir, as a window launch reads them)
--plm-token-file FILE
a file holding the API token; a worker-scoped one is
enough (else BREP_PLM_TOKEN, else the config dir's
plm-token). There is no flag holding the token itself:
a long-running process's command line is readable by
every user of the machine
--once one pass over the queue, then exit
--poll SECS seconds between passes when the queue is empty (default 30)";
#[cfg(not(target_arch = "wasm32"))]
pub fn parse_worker_args(
args: impl IntoIterator<Item = String>,
config_dir: &std::path::Path,
env: &dyn Fn(&str) -> Option<String>,
) -> Result<WorkerArgs, String> {
let mut flags = super::config::Flags::default();
let (mut once, mut poll) = (false, None);
let mut args = args.into_iter();
while let Some(arg) = args.next() {
match arg.as_str() {
"--plm-url" => flags.url = Some(args.next().ok_or("--plm-url needs the server's address")?),
"--plm-token-file" => flags.token_file = Some(args.next().ok_or("--plm-token-file needs a file")?.into()),
"--once" => once = true,
"--poll" => {
let value = args.next().ok_or("--poll needs a number of seconds")?;
poll = Some(value.parse::<u64>().map_err(|_| format!("--poll: `{value}` is not a number of seconds"))?);
}
other => return Err(format!("--bake-worker does not take `{other}`")),
}
}
if once && poll.is_some() {
return Err("--once and --poll do not go together".into());
}
let config = super::config::resolve(config_dir, env, &flags)?.ok_or_else(|| {
format!(
"the bake worker needs a server: --plm-url, BREP_PLM_URL or {}",
config_dir.join(super::config::URL_FILE).display()
)
})?;
let token = config.token.ok_or_else(|| {
format!(
"the bake worker needs a token: --plm-token-file, BREP_PLM_TOKEN or {}",
config_dir.join(super::config::TOKEN_FILE).display()
)
})?;
Ok(WorkerArgs { url: config.url, token, once, poll: poll.unwrap_or(30) })
}
#[cfg(not(target_arch = "wasm32"))]
pub fn worker_main(args: impl IntoIterator<Item = String>) -> i32 {
let args: Vec<String> = args.into_iter().collect();
if args.iter().any(|a| a == "-h" || a == "--help") {
println!("usage: {WORKER_USAGE}");
return 0;
}
let env = |name: &str| std::env::var(name).ok();
let args = match parse_worker_args(args, &super::config::app_config_dir(), &env) {
Ok(args) => args,
Err(e) => {
eprintln!("brep-app: {e}\nusage: {WORKER_USAGE}");
return 2;
}
};
let client = PlmClient::new(std::rc::Rc::new(super::transport::EhttpTransport::new(&args.url)));
match block_on(client.sign_in_with_token(&args.token)) {
Ok(me) => println!("bake worker: signed in to {} as {}", args.url, me.username),
Err(e) => {
eprintln!("brep-app: the bake worker cannot start: {e}");
return 3;
}
}
let renewal = Renewal::new(&args.url, &args.token, RENEW_EVERY);
loop {
match block_on(run_pass_with(&client, usize::MAX, Some(&renewal))) {
Ok(outcomes) => {
for outcome in &outcomes {
println!("{}", worker_line(outcome));
}
if args.once {
return 0;
}
if outcomes.is_empty() {
std::thread::sleep(std::time::Duration::from_secs(args.poll));
}
}
Err(e @ (PlmError::SignIn(_) | PlmError::Forbidden(_))) => {
eprintln!("brep-app: the bake worker was refused: {e}");
return 3;
}
Err(e) => {
eprintln!("bake worker: {e}");
if args.once {
return 3;
}
std::thread::sleep(std::time::Duration::from_secs(args.poll));
}
}
}
}
pub fn worker_line(outcome: &JobOutcome) -> String {
let job = outcome.job();
match outcome {
JobOutcome::Done { .. } => format!("{} {}: done", job.number, job.revision_label),
JobOutcome::Failed { error, .. } => format!("{} {}: failed: {error}", job.number, job.revision_label),
JobOutcome::Dropped { why, .. } => format!("{} {}: dropped: {why}", job.number, job.revision_label),
}
}