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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
//! The mechanical verbs, owned by the exe (John's pattern: validation, state and replies here,
//! the Python add-in only runs the Fusion API work in `commands/mechanical`).
//!
//! - `fusion_mech_capabilities`: the schema the add-in executes plus its live session, read from
//! the add-in's session file, so it never waits on Fusion's main thread.
//! - `fusion_mech_operation_status`: the journal, read here: it answers while Fusion is busy,
//! hung or closed, and never launches, repairs or focuses anything.
//! - `fusion_mechanical`: validate, then (writes) admit and journal, then the ordinary add-in
//! pass-through (gate, busy check, etiquette) carries the frozen request to the add-in.
//!
//! A write's dispatch claim file arbitrates "never ran" against "may have run": the add-in
//! creates `<dispatchId>.claim` before executing, the exe creates it only after the pass-through
//! returned without the add-in's answer, and whoever creates it first wins. The add-in also
//! leaves its exact result in `<dispatchId>.result.json`, so a reply lost to a timeout is still
//! recorded later (status, or the next write) - the guarantee the add-in's own journal gave.
use fusion_core::mech::{history::History, journal::{self, Journal}, validate};
use serde_json::{json, Map, Value};
use std::collections::HashSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
const SCHEMA_JSON: &str = include_str!("../../../addin/AdomBridge/commands/mechanical/schema.json");
/// The add-in protocol this exe speaks: it journals nothing itself and claims each dispatch.
pub const PROTOCOL: &str = "exe-journal-1";
/// Add-in answer fields that are not part of its operation result.
const DISPATCH_FIELDS: [&str; 5] = ["success", "dispatchId", "nativeExecutionStarted", "observedDocument", "result"];
static IN_FLIGHT: Mutex<Option<HashSet<String>>> = Mutex::new(None);
pub fn owns_status(verb: &str) -> bool { matches!(verb, "fusion_mech_operation_status" | "mech_operation_status") }
pub fn owns_capabilities(verb: &str) -> bool { matches!(verb, "fusion_mech_capabilities" | "mech_capabilities") }
fn schema() -> &'static Map<String, Value> {
static S: OnceLock<Map<String, Value>> = OnceLock::new();
S.get_or_init(|| serde_json::from_str(SCHEMA_JSON).expect("mechanical schema.json is valid JSON"))
}
fn now_f() -> f64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs_f64()).unwrap_or(0.0) }
/// One history per journal directory (one in production; tests each use their own).
fn history(state: &Path) -> &'static History {
static H: Mutex<Vec<(PathBuf, &'static History)>> = Mutex::new(Vec::new());
let mut all = H.lock().unwrap_or_else(|e| e.into_inner());
if let Some((_, h)) = all.iter().find(|(p, _)| p == state) { return h; }
let h: &'static History = Box::leak(Box::new(History::new(crate::VERSION, Journal::new(Box::new(crate::mech_store::SqliteTable::new(state.to_path_buf())), Box::new(now_f)))));
all.push((state.to_path_buf(), h));
h
}
pub struct Session { pub id: String, pub transactions: Value }
/// The add-in's `session.json`, trusted only while the Fusion process that wrote it runs.
pub fn live_session(state: &Path, fusion_pids: &[u32]) -> Option<Session> {
let s: Value = serde_json::from_str(&std::fs::read_to_string(state.join("session.json")).ok()?).ok()?;
if s["protocol"] != PROTOCOL || !s["pid"].as_u64().is_some_and(|p| fusion_pids.contains(&(p as u32))) { return None; }
Some(Session { id: s["sessionId"].as_str()?.to_string(), transactions: s["transactions"].clone() })
}
fn dispatch_dir(state: &Path) -> PathBuf { state.join("dispatch") }
fn claim_path(state: &Path, id: &str) -> PathBuf { dispatch_dir(state).join(format!("{id}.claim")) }
fn result_path(state: &Path, id: &str) -> PathBuf { dispatch_dir(state).join(format!("{id}.result.json")) }
enum Claim { Won, Started(Value), Expired, Unavailable(String) }
/// Try to expire a dispatch. Losing means the add-in already claimed it.
fn expire(state: &Path, id: &str) -> Claim {
if std::fs::create_dir_all(dispatch_dir(state)).is_err() { return Claim::Unavailable("dispatch directory unavailable".into()); }
match std::fs::OpenOptions::new().write(true).create_new(true).open(claim_path(state, id)) {
Ok(mut f) => { let _ = f.write_all(json!({"state": "expired", "at": now_f()}).to_string().as_bytes()); let _ = f.sync_all(); Claim::Won }
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => read_claim(state, id).unwrap_or(Claim::Started(Value::Null)),
Err(e) => Claim::Unavailable(e.to_string()),
}
}
/// An existing claim. A file still being written by the add-in reads as started.
fn read_claim(state: &Path, id: &str) -> Option<Claim> {
let text = std::fs::read_to_string(claim_path(state, id)).ok()?;
let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
Some(if v["state"] == "expired" { Claim::Expired } else { Claim::Started(v["observedDocument"].clone()) })
}
fn read_result(state: &Path, id: &str) -> Option<Value> {
serde_json::from_slice(&std::fs::read(result_path(state, id)).ok()?).ok().filter(Value::is_object)
}
fn clear_dispatch(state: &Path, id: &str) {
let _ = std::fs::remove_file(result_path(state, id));
let _ = std::fs::remove_file(claim_path(state, id));
}
fn descriptor_of(record: &Value) -> Value {
json!({"sessionId": record["sessionId"], "operationId": record["operationId"], "requestDigest": record["requestDigest"], "dispatchId": record["dispatchId"]})
}
/// Bring one current-session record up to date with its dispatch files: a result the add-in left
/// after its reply was lost, a claim the exe never saw, or a dispatch the exe lost before any claim.
fn reconcile(state: &Path, current: Option<&str>, record: &Value) {
let h = history(state);
let phase = record["phase"].as_str().unwrap_or_default();
let Some(id) = record["dispatchId"].as_str().filter(|_| matches!(phase, "dispatched" | "executing")) else { return };
if record["sessionId"].as_str() != current { return; }
if IN_FLIGHT.lock().unwrap().as_ref().is_some_and(|s| s.contains(id)) { return; }
let descriptor = descriptor_of(record);
let claim = read_claim(state, id).unwrap_or_else(|| expire(state, id));
match claim {
Claim::Won | Claim::Expired => {
h.not_executed(current, Some(&descriptor), &json!({"success": false, "errorCode": "request_expired_before_execution",
"error": "The bridge lost this dispatch before the add-in claimed it; it never ran."}));
clear_dispatch(state, id);
}
Claim::Started(doc) => {
if phase == "dispatched" { let _ = h.start_recorded(current, record, &doc); }
if let Some(result) = read_result(state, id) {
h.finish(current, &descriptor, &result);
clear_dispatch(state, id);
}
}
Claim::Unavailable(_) => {}
}
}
/// Results the add-in left behind are recorded before anything new is admitted, so the add-in's
/// own history proofs (exploded previews) see them. Old orphaned files are dropped after a day.
fn reconcile_all(state: &Path, current: Option<&str>) {
let Ok(dir) = std::fs::read_dir(dispatch_dir(state)) else { return };
for entry in dir.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
let Some(id) = name.strip_suffix(".claim") else { continue };
let old = entry.metadata().ok().and_then(|m| m.modified().ok()).and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 86400);
let found = read_result(state, id).or_else(|| std::fs::read_to_string(entry.path()).ok().and_then(|t| serde_json::from_str(&t).ok()));
let (s, o) = found.as_ref().map(|v| (v["sessionId"].as_str().map(str::to_string), v["operationId"].as_str().map(str::to_string))).unwrap_or((None, None));
match (s, o) {
(Some(s), Some(o)) => {
if let Ok(Some(record)) = history(state).lookup(&s, &o) {
if record["dispatchId"] == id { reconcile(state, current, &record); }
if journal::is_terminal(record["phase"].as_str().unwrap_or_default()) || record["sessionId"].as_str() != current { clear_dispatch(state, id); }
} else if old { clear_dispatch(state, id); }
}
_ if old => clear_dispatch(state, id),
_ => {}
}
}
}
pub fn capabilities(state: &Path, pids: &dyn Fn() -> Vec<u32>, ensure_ready: &dyn Fn() -> Value) -> Value {
let mut session = live_session(state, &pids());
if session.is_none() {
// Fusion closed or its add-in still starting: the ordinary gate launches and waits.
let _ = ensure_ready();
session = live_session(state, &pids());
}
let transactions = session.as_ref().map(|s| s.transactions.clone()).filter(Value::is_object)
.unwrap_or_else(|| json!({"verified": false, "backend": "PTransaction", "evidence": null, "scope": "one active Fusion Design; no document lifecycle, files or cloud effects"}));
let mut r = validate::capabilities(session.as_ref().map(|s| s.id.as_str()), &Value::Object(schema().clone()), &transactions);
if session.is_none() {
r["_hint"] = json!("No add-in session: Fusion is not running, its add-in is still loading, or the add-in predates 1.12.0-dev.10. Reads work; writes need the sessionId this verb returns once fusion_readiness is ready.");
}
r
}
pub fn status(state: &Path, pids: &[u32], args: &Value) -> Value {
let current = live_session(state, pids).map(|s| s.id);
if let (Some(s), Some(o)) = (args["sessionId"].as_str(), args["operationId"].as_str()) {
if let Ok(Some(record)) = history(state).lookup(s, o) { reconcile(state, current.as_deref(), &record); }
}
history(state).status(current.as_deref(), args)
}
/// Carry the pass-through's own additions (dialogs, etiquette notes, timing) onto the reply.
fn with_envelope(mut reply: Value, answer: &Value) -> Value {
if let (Some(r), Some(a)) = (reply.as_object_mut(), answer.as_object()) {
for (k, v) in a {
if !DISPATCH_FIELDS.contains(&k.as_str()) && !r.contains_key(k) { r.insert(k.clone(), v.clone()); }
}
}
reply
}
pub fn mechanical(state: &Path, pids: &[u32], args: &Value, pass: &dyn Fn(&str, Value) -> Value) -> Value {
let current_session = live_session(state, pids).map(|s| s.id);
let current = current_session.as_deref();
// The window etiquette reads and strips these before the add-in hop; they are not operation
// arguments, so they never reach validation or the retry digest.
let mut user = args.clone();
let mut envelope = Map::new();
if let Some(o) = user.as_object_mut() {
for k in ["foreground", "foregroundReason"] { if let Some(v) = o.remove(k) { envelope.insert(k.into(), v); } }
}
let (frozen, write) = match validate::prepare_request(&user, schema(), current) {
Ok(x) => x,
Err(e) => return validate::error_result(current, &e),
};
let mut sent = frozen.clone();
for (k, v) in &envelope { sent[k] = v.clone(); }
if !write { return pass("mechanical", sent); }
reconcile_all(state, current);
let id = crate::mech_store::hex32();
let descriptor = match history(state).admit(current, &frozen, &id) { Ok(d) => d, Err(reply) => return reply };
IN_FLIGHT.lock().unwrap().get_or_insert_with(HashSet::new).insert(id.clone());
sent["_dispatch"] = json!({"dispatchId": id});
let answer = pass("mechanical", sent);
let reply = settle(state, current, &descriptor, &frozen, &id, &answer);
if let Some(s) = IN_FLIGHT.lock().unwrap().as_mut() { s.remove(&id); }
reply
}
fn settle(state: &Path, current: Option<&str>, descriptor: &Value, frozen: &Value, id: &str, answer: &Value) -> Value {
let h = history(state);
let answered = answer["dispatchId"].as_str() == Some(id);
if answered && answer["nativeExecutionStarted"] == true {
return record_started(state, current, descriptor, frozen, id, &answer["observedDocument"], answer);
}
match expire(state, id) {
Claim::Won | Claim::Expired => {
// Never ran: the add-in refused before claiming, or the pass-through never delivered.
let result = if answered { answer["result"].clone() } else { answer.clone() };
let reply = h.not_executed(current, Some(descriptor), &result);
clear_dispatch(state, id);
if answered { with_envelope(reply, answer) } else { reply }
}
Claim::Started(doc) => record_started(state, current, descriptor, frozen, id, &doc, answer),
Claim::Unavailable(why) => {
let mut reply = h.pending_reply(current, &h.lookup(descriptor["sessionId"].as_str().unwrap_or_default(), descriptor["operationId"].as_str().unwrap_or_default()).ok().flatten().unwrap_or(Value::Null));
reply["dispatchArbitration"] = json!(why);
reply
}
}
}
fn record_started(state: &Path, current: Option<&str>, descriptor: &Value, frozen: &Value, id: &str, doc: &Value, answer: &Value) -> Value {
let h = history(state);
if let Some(pending) = h.start(current, descriptor, frozen, doc) { return with_envelope(pending, answer); }
let answered = answer["dispatchId"].as_str() == Some(id);
let result = read_result(state, id).or_else(|| if answered && answer["result"].is_object() { Some(answer["result"].clone()) } else { None });
match result {
Some(result) => {
let reply = h.finish(current, descriptor, &result);
clear_dispatch(state, id);
with_envelope(reply, answer)
}
// Executing in the add-in and its reply is lost: status (or the next write) records it.
None => {
let record = h.lookup(descriptor["sessionId"].as_str().unwrap_or_default(), descriptor["operationId"].as_str().unwrap_or_default()).ok().flatten().unwrap_or(Value::Null);
with_envelope(h.pending_reply(current, &record), answer)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
fn scratch(tag: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("mech-{tag}-{}", crate::mech_store::hex32()));
std::fs::create_dir_all(&d).unwrap();
d
}
fn session(state: &Path, id: &str, pid: u32) {
std::fs::write(state.join("session.json"), json!({"protocol": PROTOCOL, "sessionId": id, "pid": pid, "transactions": {"verified": true}}).to_string()).unwrap();
}
fn write_args(op_id: &str, sid: &str) -> Value {
json!({"op": "parameter_set", "sessionId": sid, "operationId": op_id, "target": {"documentId": "d", "revision": "r"}, "name": "w", "expression": "42 mm", "foreground": false})
}
/// The add-in side of one dispatch, as commands/mechanical does it.
fn addin(state: &Path, sent: &Value, claim_first: bool) -> Value {
let id = sent["_dispatch"]["dispatchId"].as_str().unwrap().to_string();
assert!(sent.get("foreground").is_some() && sent["_dispatch"].is_object());
if claim_first {
std::fs::create_dir_all(dispatch_dir(state)).unwrap();
std::fs::OpenOptions::new().write(true).create_new(true).open(claim_path(state, &id)).unwrap()
.write_all(json!({"state": "started", "observedDocument": {"documentId": "d"}}).to_string().as_bytes()).unwrap();
}
let result = json!({"success": true, "sessionId": sent["sessionId"], "operation": "parameter_set", "data": {"value": 42}, "operationId": sent["operationId"]});
std::fs::write(result_path(state, &id), result.to_string()).unwrap();
json!({"success": true, "dispatchId": id, "nativeExecutionStarted": true, "observedDocument": {"documentId": "d"}, "result": result, "windowEtiquette": "background"})
}
#[test]
fn a_write_is_validated_journaled_dispatched_and_replayed() {
let st = scratch("ok");
session(&st, "S1", 7);
let sent = RefCell::new(Value::Null);
let r = mechanical(&st, &[7], &write_args("w1", "S1"), &|_, a| { *sent.borrow_mut() = a.clone(); addin(&st, &a, true) });
assert_eq!(r["success"], true, "{r}");
assert_eq!(r["durability"]["terminalRecorded"], true);
assert_eq!(r["windowEtiquette"], "background");
assert!(!sent.borrow().get("foreground").is_none() && sent.borrow()["_dispatch"]["dispatchId"].is_string());
// a retry with the same ID replays the recorded result without dispatching again
let again = mechanical(&st, &[7], &write_args("w1", "S1"), &|_, _| panic!("no second dispatch"));
assert_eq!(again["replayed"], true);
let s = status(&st, &[7], &json!({"sessionId": "S1", "operationId": "w1"}));
assert_eq!(s["state"], "succeeded");
assert!(std::fs::read_dir(dispatch_dir(&st)).unwrap().next().is_none(), "dispatch files cleared");
}
#[test]
fn a_refusal_before_the_add_in_claims_is_recorded_as_never_run() {
let st = scratch("busy");
session(&st, "S1", 7);
let r = mechanical(&st, &[7], &write_args("w2", "S1"), &|_, _| json!({"success": false, "errorCode": "main_thread_busy", "error": "busy"}));
assert_eq!(r["durability"]["phase"], "not_executed", "{r}");
assert_eq!(r["nativeExecutionStarted"], false);
assert_eq!(status(&st, &[7], &json!({"sessionId": "S1", "operationId": "w2"}))["state"], "failed");
}
#[test]
fn a_reply_lost_after_the_claim_is_recorded_from_the_result_file() {
let st = scratch("lost");
session(&st, "S1", 7);
let r = mechanical(&st, &[7], &write_args("w3", "S1"), &|_, a| {
let id = a["_dispatch"]["dispatchId"].as_str().unwrap().to_string();
std::fs::create_dir_all(dispatch_dir(&st)).unwrap();
std::fs::write(claim_path(&st, &id), json!({"state": "started", "observedDocument": null}).to_string()).unwrap();
json!({"success": false, "error": "Add-in request failed: timed out"})
});
assert_eq!(r["errorCode"], "operation_in_progress", "{r}");
// the add-in finishes later and leaves its result; status records it
let rec = history(&st).lookup("S1", "w3").unwrap().unwrap();
let id = rec["dispatchId"].as_str().unwrap();
std::fs::write(result_path(&st, id), json!({"success": true, "sessionId": "S1", "operation": "parameter_set", "data": {}, "operationId": "w3"}).to_string()).unwrap();
let s = status(&st, &[7], &json!({"sessionId": "S1", "operationId": "w3"}));
assert_eq!(s["state"], "succeeded", "{s}");
}
#[test]
fn a_ray_batch_is_validated_and_answered_without_a_journal_record() {
let st = scratch("rays");
session(&st, "S1", 7);
let ray = json!({"origin": [30, 30, 100], "direction": [0, 0, -1], "length": 200, "against": [0]});
let batch = |rays: Value| json!({"op": "ray_cast", "target": {"documentId": "d"}, "unit": "mm", "occurrences": ["Plate:1"], "rays": rays});
let sent = RefCell::new(Value::Null);
let r = mechanical(&st, &[7], &batch(json!([ray])), &|cmd, a| {
assert_eq!(cmd, "mechanical");
*sent.borrow_mut() = a.clone();
json!({"success": true, "operation": "ray_cast", "data": {"hits": [[[0, 90.0]]], "cast": 1, "complete": true}})
});
assert_eq!(r["data"]["hits"], json!([[[0, 90.0]]]), "{r}");
assert!(sent.borrow().get("_dispatch").is_none(), "a read carries no dispatch");
assert!(!st.join("operations-v1.sqlite3").exists(), "a ray batch leaves no journal record");
// a malformed ray, or one too many, is refused here and never reaches Fusion
let mut too_many = Vec::new();
too_many.resize(8193, ray.clone());
for bad in [json!([{"origin": [0, 0], "direction": [0, 0, 1], "length": 1, "against": [0]}]),
json!([{"origin": [0, 0, 0], "direction": [0, 0, 1], "length": 1, "against": ["0"]}]),
json!([{"origin": [0, 0, 0], "direction": [0, 0, 1], "length": 0, "against": [0]}]),
json!([{"origin": [0, 0, 0], "direction": [0, 0, 1], "against": [0]}]),
json!([{"origin": [0, 0, 0], "direction": [0, 0, 1], "length": 1, "against": [0], "weight": 2}]),
json!(too_many)] {
let r = mechanical(&st, &[7], &batch(bad), &|_, _| panic!("refused before the add-in"));
assert_eq!(r["errorCode"], "invalid_input", "{r}");
}
}
#[test]
fn an_assembly_layout_is_journaled_like_the_other_long_reads() {
let st = scratch("layout");
session(&st, "S1", 7);
let args = json!({"op": "assembly_layout", "sessionId": "S1", "operationId": "l1", "scope": "Panel:1"});
let r = mechanical(&st, &[7], &args, &|_, a| {
let id = a["_dispatch"]["dispatchId"].as_str().unwrap().to_string();
std::fs::create_dir_all(dispatch_dir(&st)).unwrap();
std::fs::write(claim_path(&st, &id), json!({"state": "started", "observedDocument": {"documentId": "d"}}).to_string()).unwrap();
let result = json!({"success": true, "sessionId": "S1", "operation": "assembly_layout", "operationId": "l1",
"data": {"target": {"documentId": "d", "revision": "r"}, "units": [], "joints": [], "rigidGroups": []}});
json!({"success": true, "dispatchId": id, "nativeExecutionStarted": true, "observedDocument": {"documentId": "d"}, "result": result})
});
assert_eq!(r["durability"]["terminalRecorded"], true, "{r}");
assert_eq!(r["data"]["target"]["revision"], "r");
assert_eq!(status(&st, &[7], &json!({"sessionId": "S1", "operationId": "l1"}))["state"], "succeeded");
// journaled, so it needs the live session and an operation ID like any write
let r = mechanical(&st, &[7], &json!({"op": "assembly_layout", "sessionId": "S0", "operationId": "l2"}), &|_, _| panic!("never dispatched"));
assert_eq!(r["errorCode"], "session_mismatch");
}
#[test]
fn writes_need_the_live_session_and_reads_do_not() {
let st = scratch("session");
session(&st, "S1", 7);
let r = mechanical(&st, &[8], &write_args("w4", "S1"), &|_, _| panic!("never dispatched"));
assert_eq!(r["errorCode"], "session_mismatch");
assert_eq!(r["sessionId"], Value::Null);
let read = mechanical(&st, &[8], &json!({"op": "inspect"}), &|cmd, a| json!({"cmd": cmd, "args": a}));
assert_eq!(read["cmd"], "mechanical");
assert_eq!(read["args"], json!({"op": "inspect"}));
let caps = capabilities(&st, &|| vec![7], &|| panic!("session already live"));
assert_eq!(caps["sessionId"], "S1");
assert_eq!(caps["transactions"]["typedModelOperations"]["verified"], true);
}
}