app
Fusion - the Fusion 360 Bridge
Public Made by Adomby adom
Drive Autodesk Fusion 360 from the cloud via Adom Bridge: component libraries, IPC package generation, board layout, exports (STEP/Gerbers/BOM/CPL), fast APS cloud search, and parametric modeling.
master
Drew Owens
The mechanical layer on 1.12.8 with the restart fixes (1.13.0-dev.1, add-in 1.0.23)
2ef0220
15d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
//! The durable operation journal (the add-in's journal.py), ported. Records are canonical JSON
//! rows keyed by (session, operation); every read re-validates the digest, identity, phase and
//! canonical encoding, so a row written by the Python add-in decodes here and the reverse.
//! Storage itself is behind [`Table`]: SQLite in the exe (`bridge/src/mech_store.rs`), memory
//! in the golden tests. No replay, no reset of corrupt storage, no eviction.
use super::canonical;
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::sync::Mutex;
pub const STORAGE_VERSION: u64 = 1;
pub const APPLICATION_ID: i64 = 0x41444D4D;
pub const MAX_RECORDS: u64 = 10000;
pub const MAX_DATABASE_BYTES: u64 = 64 * 1024 * 1024;
pub const MAX_RECORD_BYTES: usize = 4 * 1024 * 1024;
pub const MAX_REQUEST_BYTES: usize = 1024 * 1024;
pub const TERMINAL: [&str; 3] = ["succeeded", "failed", "not_executed"];
pub const PHASES: [&str; 6] = ["admitted", "dispatched", "executing", "succeeded", "failed", "not_executed"];
#[derive(Debug, Clone, PartialEq)]
pub struct JournalError {
pub code: String,
pub message: String,
/// Set for `operation_conflict` raised by admission: (session, operation, submitted, existing).
pub conflict: Option<(String, String, String, String)>,
}
impl JournalError {
pub fn new(code: &str, message: impl Into<String>) -> Self { JournalError { code: code.into(), message: message.into(), conflict: None } }
}
fn require(ok: bool, code: &str, message: &str) -> Result<(), JournalError> {
if ok { Ok(()) } else { Err(JournalError::new(code, message)) }
}
pub fn is_terminal(phase: &str) -> bool { TERMINAL.contains(&phase) }
pub fn sha256_hex(bytes: &[u8]) -> String { Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() }
pub fn canonical(value: &Value, maximum: usize) -> Result<Vec<u8>, JournalError> {
let data = canonical::encode(value);
require(data.len() <= maximum, "operation_record_too_large", "Operation record exceeds its durable byte limit.")?;
Ok(data)
}
pub fn fingerprint(request: &Value) -> Result<String, JournalError> { Ok(sha256_hex(&canonical(request, MAX_REQUEST_BYTES)?)) }
/// One row: session, operation, canonical record bytes, sha256 of those bytes.
pub type Row = (String, String, Vec<u8>, String);
/// The storage under the journal. `open(false)` answers None when no journal exists yet (a
/// history read never creates storage); `open(true)` begins an immediate write transaction.
pub trait Table: Send + Sync {
fn open(&self, create: bool) -> Result<Option<Box<dyn Tx + '_>>, JournalError>;
}
pub trait Tx {
fn get(&mut self, session: &str, operation: &str) -> Result<Option<(Vec<u8>, String)>, JournalError>;
fn all(&mut self) -> Result<Vec<Row>, JournalError>;
fn count(&mut self) -> Result<u64, JournalError>;
fn put(&mut self, row: &Row) -> Result<(), JournalError>;
fn commit(self: Box<Self>) -> Result<(), JournalError>;
}
/// `_decode`: identity, phase, result shape and canonical encoding must all hold.
pub fn decode(raw: &[u8], digest: &str, session: &str, operation: &str, max_record_bytes: usize) -> Result<Value, JournalError> {
require(raw.len() <= max_record_bytes && sha256_hex(raw) == digest, "operation_storage_corrupt", "Operation record digest or size is invalid.")?;
let malformed = || JournalError::new("operation_storage_corrupt", "Operation record is malformed.");
let record: Value = serde_json::from_slice(raw).map_err(|_| malformed())?;
let field = |k: &str| record.get(k).ok_or_else(malformed);
let finite = |v: &Value| match v { Value::Number(n) => Ok(n.as_f64().is_some_and(f64::is_finite)), Value::Bool(_) => Ok(true), _ => Err(malformed()) };
let phase = field("phase")?;
let events = field("events")?;
let digest_ok = field("requestDigest")?.as_str().is_some_and(|d| d.chars().count() == 64);
let mut valid = super::validate::py_eq(field("storageVersion")?, &json!(STORAGE_VERSION))
&& field("sessionId")? == session && field("operationId")? == operation
&& phase.as_str().is_some_and(|p| PHASES.contains(&p)) && digest_ok
&& events.as_array().is_some_and(|e| (1..=6).contains(&e.len()))
&& finite(field("createdAt")?)? && finite(field("updatedAt")?)?;
let result = field("result")?;
if phase.as_str().is_some_and(is_terminal) {
let success = result.as_object().and_then(|r| r.get("success")).and_then(Value::as_bool);
valid = valid && success.is_some() && success == Some(phase == "succeeded");
if result.as_object().is_none() { valid = false; }
} else {
valid = valid && result.is_null();
}
require(valid && canonical(&record, max_record_bytes)? == raw, "operation_storage_corrupt", "Operation record identity, phase or canonical encoding is invalid.")?;
Ok(record)
}
pub struct Journal {
table: Box<dyn Table>,
clock: Box<dyn Fn() -> f64 + Send + Sync>,
pub max_records: u64,
pub max_record_bytes: usize,
lock: Mutex<()>,
}
pub struct Admitted { pub created: bool, pub record: Value }
pub struct Transitioned { pub claimed: bool, pub record: Value }
impl Journal {
pub fn new(table: Box<dyn Table>, clock: Box<dyn Fn() -> f64 + Send + Sync>) -> Self {
Journal { table, clock, max_records: MAX_RECORDS, max_record_bytes: MAX_RECORD_BYTES, lock: Mutex::new(()) }
}
fn read(&self, tx: &mut dyn Tx, session: &str, operation: &str) -> Result<Option<Value>, JournalError> {
match tx.get(session, operation)? {
Some((raw, digest)) => decode(&raw, &digest, session, operation, self.max_record_bytes).map(Some),
None => Ok(None),
}
}
fn save(&self, tx: &mut dyn Tx, record: &Value) -> Result<(), JournalError> {
let raw = canonical(record, self.max_record_bytes)?;
let digest = sha256_hex(&raw);
let (session, operation) = (record["sessionId"].as_str().unwrap_or_default().to_string(), record["operationId"].as_str().unwrap_or_default().to_string());
decode(&raw, &digest, &session, &operation, self.max_record_bytes)?;
tx.put(&(session, operation, raw, digest))
}
/// `_run`: one transaction per call; any non-journal failure becomes operation_storage_failed
/// (the store maps its own errors), and nothing is retried or reset here.
fn run<T>(&self, create: bool, action: impl FnOnce(&mut dyn Tx) -> Result<T, JournalError>) -> Result<Option<T>, JournalError> {
let _guard = self.lock.lock().unwrap_or_else(|e| e.into_inner());
let Some(mut tx) = self.table.open(create)? else { return Ok(None) };
let out = action(&mut *tx)?;
if create { tx.commit()?; }
Ok(Some(out))
}
pub fn lookup(&self, session: &str, operation: &str) -> Result<Option<Value>, JournalError> {
Ok(self.run(false, |tx| self.read(tx, session, operation))?.flatten())
}
pub fn admit(&self, request: &Value, source_version: &str) -> Result<Admitted, JournalError> {
let raw_request = canonical(request, MAX_REQUEST_BYTES)?;
let request: Value = serde_json::from_slice(&raw_request).map_err(|e| JournalError::new("operation_record_invalid", e.to_string()))?;
let digest = sha256_hex(&raw_request);
require(request.is_object(), "operation_record_invalid", "Operation request must be an object.")?;
let bounded = |v: &Value| v.as_str().is_some_and(|s| (1..=128).contains(&s.chars().count()));
require(bounded(&request["sessionId"]) && bounded(&request["operationId"]), "operation_record_invalid", "Session and operation IDs must be bounded strings.")?;
require(bounded(&request["op"]) && (1..=128).contains(&source_version.chars().count()), "operation_record_invalid", "Operation name and source version must be bounded strings.")?;
let (session, operation) = (request["sessionId"].as_str().unwrap().to_string(), request["operationId"].as_str().unwrap().to_string());
let out = self.run(true, |tx| {
for (sid, oid, payload, checksum) in tx.all()? { decode(&payload, &checksum, &sid, &oid, self.max_record_bytes)?; }
if let Some(existing) = self.read(tx, &session, &operation)? {
let existing_digest = existing["requestDigest"].as_str().unwrap_or_default().to_string();
if existing_digest != digest {
let mut e = JournalError::new("operation_conflict", "operationId was already used with different arguments.");
e.conflict = Some((session.clone(), operation.clone(), digest.clone(), existing_digest));
return Err(e);
}
return Ok(Admitted { created: false, record: existing });
}
require(tx.count()? < self.max_records, "operation_capacity", "Durable operation history is full; no records are evicted.")?;
let now = (self.clock)();
let record = json!({"storageVersion": STORAGE_VERSION, "sessionId": session, "operationId": operation,
"operation": request["op"], "sourceVersion": source_version, "requestDigest": digest,
"requestedTarget": request.get("target").cloned().unwrap_or(Value::Null), "observedDocument": null,
"phase": "admitted", "createdAt": now, "updatedAt": now,
"events": [{"phase": "admitted", "at": now}], "dispatchId": null, "result": null});
self.save(tx, &record)?;
Ok(Admitted { created: true, record })
})?;
out.ok_or_else(|| JournalError::new("operation_storage_failed", "Operation storage could not be opened."))
}
pub fn transition(&self, session: &str, operation: &str, digest: &str, phase: &str, dispatch_id: Option<&str>,
document: Option<&Value>, result: Option<&Value>) -> Result<Transitioned, JournalError> {
let out = self.run(true, |tx| {
let record = self.read(tx, session, operation)?;
let Some(mut record) = record.filter(|r| r["requestDigest"] == digest) else {
return Err(JournalError::new("operation_conflict", "Operation claim does not match its durable admission."));
};
let old = record["phase"].as_str().unwrap_or_default().to_string();
let allowed: &[&str] = match phase {
"dispatched" => &["admitted"], "executing" => &["dispatched"], "not_executed" => &["admitted", "dispatched"],
"succeeded" | "failed" => &["executing"],
_ => return Err(JournalError::new("operation_record_invalid", "Unsupported operation phase transition.")),
};
if !allowed.contains(&old.as_str()) { return Ok(Transitioned { claimed: false, record }); }
if old == "dispatched" || old == "executing" {
require(dispatch_id.is_some_and(|d| !d.is_empty()) && record["dispatchId"].as_str() == dispatch_id, "operation_conflict", "Dispatch generation does not match the durable record.")?;
}
if phase == "dispatched" {
require(dispatch_id.is_some_and(|d| (1..=128).contains(&d.chars().count())), "operation_record_invalid", "Dispatch ID is required.")?;
record["dispatchId"] = json!(dispatch_id);
}
if phase == "executing" { record["observedDocument"] = document.cloned().unwrap_or(Value::Null); }
if is_terminal(phase) {
let ok = result.and_then(|r| r.get("success")).and_then(Value::as_bool);
require(result.is_some_and(Value::is_object) && ok == Some(phase == "succeeded"), "operation_record_invalid", "Terminal phase contradicts its result.")?;
record["result"] = result.cloned().unwrap_or(Value::Null);
}
let now = (self.clock)();
record["phase"] = json!(phase);
record["updatedAt"] = json!(now);
if let Some(events) = record["events"].as_array_mut() { events.push(json!({"phase": phase, "at": now})); }
self.save(tx, &record)?;
Ok(Transitioned { claimed: true, record })
})?;
out.ok_or_else(|| JournalError::new("operation_storage_failed", "Operation storage could not be opened."))
}
}