The .modelcraft file, undo/redo on the journal, atomic saves and autosave recovery (M3) (#21)

* modelcraft-io: the .modelcraft file (modelcraft/1), atomic writes, autosave storage

A stored ZIP of a manifest (format tag, geometry sources with SHA-256,
part sizes and hashes), the model definition as stable pretty JSON, the
mesh and geometry tags in a compact binary part, and the journal one
command per line. Reading caps every size, checks every checksum and
replays the model through its checked methods. Model::without_mesh and
empty mesh fields left out of the model's JSON; geometry::read_file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: undo/redo on the journal, file.open/save/save_as, autosave and recovery

CommandSpec gains a kind: Model commands are journalled and become undo
steps; Session commands (file.*, edit.*) manage the session. Undo history
is bounded by steps and estimated memory. Geometry sources are recorded
with content hashes and reopened on file.open (missing or changed files
are warnings). Desktop app autosaves every 30 s and reports recoverable
models; Ctrl/Cmd+Z and Shift+Z/Y undo and redo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: the .modelcraft format, undo history, roadmap and parity status

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* simplify: shared path parameter, one load path for open/recover, history accounting in History

Session commands skip the per-command model copy; saved revision is an
Option; journal written from borrowed entries and read without an
intermediate tree; backups copied by the file system; recovery lock and
error helpers; geometry file wrappers use read_file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 17:22:01 -04:00
1 parent 9fb00317ad
commit 2d99a8571c
32 files changed
+2606 -54

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
//! Autosave for recovery after a crash.
//!
//! The desktop app keeps an [`Autosaver`] and calls [`Autosaver::save`] on a timer: while the
//! session has unsaved changes not yet autosaved, the whole model is written to the app's entry in
//! the recovery folder ([`modelcraft_io::recovery`]); once nothing is unsaved the entry goes. After
//! a crash the next launch lists the entry (`file.recovery.list`) and opens it (`file.recover`).
use std::path::Path;
use modelcraft_io::recovery::Slot;
use crate::{Result, Session};
pub use modelcraft_io::recovery::default_dir;
/// One app's autosaver.
#[derive(Debug)]
pub struct Autosaver {
slot: Slot,
/// The revision last autosaved.
saved: Option<u64>,
}
impl Autosaver {
/// Start autosaving into `dir` (created if needed).
pub fn new(dir: &Path) -> Result<Autosaver> {
Ok(Autosaver { slot: Slot::new(dir)?, saved: None })
}
/// The id of this app's entry.
pub fn id(&self) -> &str {
self.slot.id()
}
/// Autosave if the session has unsaved changes not written yet, or drop the entry when
/// nothing is unsaved. Returns whether it wrote.
pub fn save(&mut self, s: &Session) -> Result<bool> {
if !s.is_dirty() {
self.slot.clear();
self.saved = None;
return Ok(false);
}
if self.saved == Some(s.revision()) {
return Ok(false);
}
// Geometry paths stay absolute: the entry is not next to the model's own file.
let bytes = s.file_bytes(None)?;
self.slot.write(&bytes, s.path.as_deref(), s.model.nodes().len(), s.model.elements().len())?;
self.saved = Some(s.revision());
Ok(true)
}
/// The app is closing: a last autosave, then the lock is released. Unsaved work stays in the
/// recovery folder for the next launch to offer.
pub fn close(mut self, s: &Session) -> Result<()> {
let saved = self.save(s);
self.slot.close();
saved.map(|_| ())
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn autosaves_unsaved_work_and_recovers_it_after_a_crash() {
let dir = std::env::temp_dir().join(format!("modelcraft-autosave-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut s = Session::default();
let mut a = Autosaver::new(&dir).unwrap();
assert!(!a.save(&s).unwrap(), "nothing unsaved");
s.execute("mesh.node.add", &json!({"label": 1, "xyz": [1, 2, 3]})).unwrap();
assert!(a.save(&s).unwrap());
assert!(!a.save(&s).unwrap(), "unchanged since the last autosave");
let id = a.id().to_string();
let listed = s.execute("file.recovery.list", &json!({"dir": dir})).unwrap();
assert_eq!(listed, json!([]), "a running app's autosave is not offered");
// The app dies.
drop(a);
let listed = s.execute("file.recovery.list", &json!({"dir": dir})).unwrap();
assert_eq!(listed[0]["id"], id);
assert_eq!(listed[0]["nodes"], 1);
let mut next = Session::default();
let r = next.execute("file.recover", &json!({"id": id, "dir": dir})).unwrap();
assert_eq!(r["nodes"], 1);
assert_eq!(next.model, s.model);
assert_eq!(next.journal, s.journal);
assert!(next.is_dirty(), "recovered work is unsaved");
next.execute("file.recovery.discard", &json!({"all": true, "dir": dir})).unwrap();
assert_eq!(next.execute("file.recovery.list", &json!({"dir": dir})).unwrap(), json!([]));
// Undoing back to the saved state drops the entry; a clean close leaves nothing.
let mut a = Autosaver::new(&dir).unwrap();
let mut s = Session::default();
s.execute("mesh.node.add", &json!({"label": 1, "xyz": [1, 2, 3]})).unwrap();
assert!(a.save(&s).unwrap());
s.execute("edit.undo", &json!({})).unwrap();
assert!(!a.save(&s).unwrap());
a.close(&s).unwrap();
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
}